往期文章:

《00. 文章合集目录》

《10. 揭秘 Compose 原理》

《11. Google 强推的 Baseline Profiles 国内能用吗?我找 Google 工程师求证了!》

你好,我是朱涛。这是「深思录」的第五篇文章。

在上一篇博客《2 小时入门 Jetpack Compose(上)》里,咱们现已完成了 Splash 页面的 UI 和动画了。

2小时入门Jetpack Compose(下)

今日这篇博客,让咱们来完成:「主页」 + 「详情页」吧。

主页

跟 Splash 页面不相同,ToDoApp 的主页,要复杂不少。从结构上来讲,它首要分为三个部分:

  • 榜首,页面顶部的 TopBar
  • 第二,页面的首要内容 Content,也便是“待完成的使命列表”;
  • 第三,页面右下角的 FloatingActionButton

看起来的确复杂不少,对吧?不过,凭借 Compose 的 Scaffold,咱们其实能快速完成这样的页面结构。

// 代码段 1
@Composable
fun HomeScreen() {
    Scaffold(
        scaffoldState = scaffoldState,
        // 1
        topBar = {
            HomeAppBar()
        },
        // 2
        content = {
            HomeContent()
        },
        // 3
        floatingActionButton = {
            HomeFab()
        }
    )
}
// 1
@Composable
fun HomeAppBar() {}
// 2
@Composable
fun HomeContent() {}
// 3
@Composable
fun HomeFab() {}

万丈高楼平地起,尽管 HomeScreen 完整的代码有 500 多行,但它最基础的结构,上面的十多行代码就能归纳。这都要感谢 Google 官方给咱们供给的 Scaffold() 函数:

// 代码段 2
@Composable
fun Scaffold(
    modifier: Modifier = Modifier,
    scaffoldState: ScaffoldState = rememberScaffoldState(),
    topBar: @Composable () -> Unit = {},
    bottomBar: @Composable () -> Unit = {},
    snackbarHost: @Composable (SnackbarHostState) -> Unit = { SnackbarHost(it) },
    floatingActionButton: @Composable () -> Unit = {},
    floatingActionButtonPosition: FabPosition = FabPosition.End,
    isFloatingActionButtonDocked: Boolean = false,
    drawerContent: @Composable (ColumnScope.() -> Unit)? = null,
    drawerGesturesEnabled: Boolean = true,
    drawerShape: Shape = MaterialTheme.shapes.large,
    drawerElevation: Dp = DrawerDefaults.Elevation,
    drawerBackgroundColor: Color = MaterialTheme.colors.surface,
    drawerContentColor: Color = contentColorFor(drawerBackgroundColor),
    drawerScrimColor: Color = DrawerDefaults.scrimColor,
    backgroundColor: Color = MaterialTheme.colors.background,
    contentColor: Color = contentColorFor(backgroundColor),
    content: @Composable (PaddingValues) -> Unit
) {}

能够看到,Scaffold() 支持的参数十分多,咱们仅仅用到了它很小的一部分,它不只支持TopBarFloatingActionButton,还支持DrawerBottomBar,这些都是开箱即用的,咱们只需求做少许配置即可。这也是 XML 无法比拟的。

整个主页的骨架现已完成了,接下来,咱们一同看看各个组件怎样完成吧。

TopBar

首要,是主页顶部的 TopBar。

假如你仅仅想在 TopBar 上展现一个静态的 Title 的话,那是十分简略的。不过,这儿我增加了一个快捷的「清空使命」操作。

2小时入门Jetpack Compose(下)

只要用户点击这个「清空使命」的按钮,就会清空当时所有的使命。为了做到这一点,咱们就需求传一个回调进来,这样便利数据层的操作。这也是 Google 推崇的 Hoisting 思维。这儿,咱们能够看做是“事情提高”。

// 代码段 3
@Composable
fun HomeAppBar(
    onDeleteAllConfirmed : () -> Unit
) {
    HomeTopAppBar(
        onDeleteAllConfirmed = {
            onDeleteAllConfirmed()
        }
    )
}

有了 Hosting 奠定基础今后,后面就很简略了,凭借 Google 供给的 TopAppBar() 咱们轻松就能完成能完成。

// 代码段 4
@Composable
fun HomeTopAppBar(
    onDeleteAllConfirmed: () -> Unit
) {
    TopAppBar(
        // 1,Title:使命列表
        title = {
            Text(
                text = stringResource(id = R.string.list_screen_title),
                color = MaterialTheme.colors.topAppBarContent
            )
        },
        // 2,选项:清空使命
        actions = {
            HomeAppBarActions(
                onDeleteAllConfirmed = onDeleteAllConfirmed
            )
        },
        backgroundColor = MaterialTheme.colors.topAppBarBackground
    )
}
@Composable
fun HomeAppBarActions(
    onDeleteAllConfirmed: () -> Unit
) {
    DeleteAllAction(onDeleteAllConfirmed = { isShowDialog = true })
}

能够看到,Title 的展现很简略,仅仅读取了一下 String 而已。而对应的「清空使命」选项,咱们则需求通过 DropdownMenu 来完成。

// 代码段 5
@Composable
fun DeleteAllAction(
    onDeleteAllConfirmed: () -> Unit
) {
    var expanded by remember { mutableStateOf(false) }
    IconButton(
        onClick = { expanded = true }
    ) {
        // 1,更多按钮
        Icon(
            painter = painterResource(id = R.drawable.ic_more),
            contentDescription = stringResource(id = R.string.delete_all_action),
            tint = MaterialTheme.colors.topAppBarContent
        )
        // 2,下拉列表
        DropdownMenu(
            expanded = expanded,
            onDismissRequest = { expanded = false }
        ) {
            // 3,下拉列表,「清空使命」
            DropdownMenuItem(
                onClick = {
                    expanded = false
                    onDeleteAllConfirmed()
                }
            ) {
                Text(
                    modifier = Modifier
                        .padding(start = MEDIUM_PADDING),
                    text = stringResource(id = R.string.delete_all_action),
                    style = Typography.subtitle2
                )
            }
        }
    }
}

不过,由于「清空使命」是一个十分风险的操作,为了避免用户误操作,咱们需求让用户「二次确认」。这时分,咱们需求在 Compose 傍边完成一个弹窗才行

2小时入门Jetpack Compose(下)

在早年的 View 系统傍边,弹窗是十分简略的。这在 Compose 傍边也并不难,但完成方法却不太相同。


@Composable
fun TodoAlertDialog(
    title: String,
    msg: String,
    isShowDialog: Boolean,    // 1
    onNoClicked: () -> Unit,  // 2
    onYesClicked: () -> Unit, // 3
) {
    if (isShowDialog) {       // 4
        AlertDialog(
            title = {
                Text(
                    text = title,
                    fontSize = MaterialTheme.typography.h5.fontSize,
                    fontWeight = FontWeight.Bold
                )
            },
            text = {
                Text(
                    text = msg,
                    fontSize = MaterialTheme.typography.subtitle1.fontSize,
                    fontWeight = FontWeight.Normal
                )
            },
            confirmButton = {
                Button(
                    onClick = {
                        onYesClicked()
                        onNoClicked()
                    })
                {
                    Text(text = stringResource(id = R.string.yes))
                }
            },
            dismissButton = {
                OutlinedButton(onClick = { onNoClicked() })
                {
                    Text(text = stringResource(id = R.string.no))
                }
            },
            onDismissRequest = { onNoClicked() }
        )
    }
}

请留心上面的注释 1、2、3,这儿其实也再次表现了 Hoisting 的思维,这儿不只包括「事情提高」,还包括了「状况提高」的思维。

别的,请留心注释 4,发现了吗?在 Compose 傍边,咱们是通过控制状况(isShowDialog)来完成弹窗的「展现」与「隐藏」的。这跟咱们的 View 系统的 Dialog.show()Dialog.hide() 的逻辑是不相同的。

至此,咱们主页的 TopBar 就算完成了,接下来咱们看看「使命列表」怎样完成吧。

使命列表

主页的使命列表,它完成起来,其实要比传统的 RecyclerView 简略不少。

首要,咱们需求判断一下,当时的使命列表是否为空。

@Composable
fun HomeContent(
    allTasks: RequestState<List<Task>>,
    onSwipeToDelete: (Task) -> Unit,
    gotoTaskDetail: (taskId: Int) -> Unit,
    onUpdateTask: (Task) -> Unit
) {
    if (allTasks is RequestState.Success &&
        allTasks.data.isNotEmpty()
    ) {
        // 不为空
        HomeTasksColumn()
    } else {
        // 空页面
        HomeEmptyContent()
    }
}

空页面没什么好讲的,便是画个简略的 UI 页面而已,咱们重点看看HomeTasksColumn()

假如咱们仅仅想要单纯的展现使命列表的话,几行代码就能够搞定了。

@Composable
fun HomeTasksColumn1(
    tasks: List<Task>,
    gotoTaskDetail: (taskId: Int) -> Unit,
    onUpdateTask: (Task) -> Unit,
) {
    LazyColumn {
        itemsIndexed(
            items = tasks,
            key = { _, task ->
                task.id
            }
        ) { index, task ->
            TaskItem(
                task = task,
                gotoTaskDetail = gotoTaskDetail,
                onUpdateTask = onUpdateTask
            )
        }
    }
}

假如是早年的 View 系统,咱们不只要写 XML,还要写 LayoutManager、Adapter、数据绑定、更新,哎,想想都觉得烦。

Compose 诱人的地方在于,它强壮的动画 Api。简略的几行代码,咱们就能够完成一些炫酷的效果。比如,这个出场的动效

2小时入门Jetpack Compose(下)

假如让你在 RecyclerView 上完美完成一个类似的效果,你要花多长时刻?3 天?仍是 3 小时?假如是在 Compose 傍边,我只需求 1 分钟


@Composable
fun HomeTasksColumn1(
    tasks: List<Task>,
    gotoTaskDetail: (taskId: Int) -> Unit,
    onUpdateTask: (Task) -> Unit,
) {
    LazyColumn {
        itemsIndexed(
            items = tasks,
            key = { _, task ->
                task.id
            }
        ) { index, task ->
            val size = remember(tasks) {
                tasks.size
            }
            // 省掉部分代码
            AnimatedVisibility(
                visible = true,
                enter = slideInHorizontally(
                    animationSpec = tween(
                        durationMillis = 300
                    ),
                    // index 越大,初始偏移越大
                    initialOffsetX = { -(it * (index + 1) / (size + 2)) }
                ),
                exit = shrinkVertically(
                    animationSpec = tween(
                        durationMillis = 300
                    )
                )
            ) {
                TaskItem(
                    task = task,
                    gotoTaskDetail = gotoTaskDetail,
                    onUpdateTask = onUpdateTask
                )
            }
        }
    }
}

我只能说,Compose 真的太强了。

OK,「出场动效」有了,假如咱们想完成「侧滑删去」的功用呢?

嗯…… 给我一首歌的时刻吧~


@Composable
fun HomeTasksColumn(
    tasks: List<Task>,
    onSwipeToDelete: (Task) -> Unit,
    gotoTaskDetail: (taskId: Int) -> Unit,
    onUpdateTask: (Task) -> Unit,
) {
    LazyColumn {
        itemsIndexed(
            items = tasks,
            key = { _, task ->
                task.id
            }
        ) { index, task ->
            val size = remember(tasks) {
                tasks.size
            }
            // 省掉部分
            AnimatedVisibility(
                visible = itemAppeared && !isDismissed,
                enter = slideInHorizontally(
                    animationSpec = tween(
                        durationMillis = 300
                    ),
                    initialOffsetX = { -(it * (index + 1) / (size + 2)) }
                ),
                exit = shrinkVertically(
                    animationSpec = tween(
                        durationMillis = 300
                    )
                )
            ) {
                // 1,改变
                SwipeToDismiss(
                    state = dismissState,
                    directions = setOf(DismissDirection.EndToStart),
                    dismissThresholds = { FractionalThreshold(fraction = 0.2f) },
                    background = { SwipeBackground(degrees = degrees) },
                    dismissContent = {
                        TaskItem(
                            task = task,
                            gotoTaskDetail = gotoTaskDetail,
                            onUpdateTask = onUpdateTask
                        )
                    }
                )
            }
        }
    }
}

请留心注释 1,「侧滑删去」这个功用,官方现已帮咱们封装好了,只需求使用 SwipeToDismiss()即可。

不过,咱们怎样会满足于这种默认效果呢?

让咱们来做个炫酷的「侧滑动效」吧~

2小时入门Jetpack Compose(下)

这个效果看着好像挺费事,但这在 Compose 傍边仅仅小菜一碟

@Composable
fun HomeTasksColumn(
    tasks: List<Task>,
    onSwipeToDelete: (Task) -> Unit,
    gotoTaskDetail: (taskId: Int) -> Unit,
    onUpdateTask: (Task) -> Unit,
) {
    LazyColumn {
        itemsIndexed(
            items = tasks,
            key = { _, task ->
                task.id
            }
        ) { index, task ->
            // 省掉部分
            // 1,改变
            val degrees by animateFloatAsState(
                if (dismissState.targetValue == DismissValue.Default)
                    0f
                else
                    -180f
            )
            // 省掉部分
            AnimatedVisibility() {
            }
        }
    }
}

在咱们手指拖动 Item 的时分,垃圾桶图标会做一个「倾倒动画」,这本质上便是一个旋转动画而已。一个 animateFloatAsState{} 就能搞定了。

不过,在侧滑的过程中,咱们还期望 Toast 提示用户,什么时分能够松手,这该怎样办呢?

其实也不难,咱们看看代码:

@Composable
fun HomeTasksColumn(
    tasks: List<Task>,
    onSwipeToDelete: (Task) -> Unit,
    gotoTaskDetail: (taskId: Int) -> Unit,
    onUpdateTask: (Task) -> Unit,
) {
    LazyColumn {
        itemsIndexed(
            items = tasks,
            key = { _, task ->
                task.id
            }
        ) { index, task ->
            // 省掉部分
            // 1
            val isDeleteEnable by remember(degrees) {
                derivedStateOf { degrees == -180f }
            }
            val context = LocalContext.current
            // 2
            DisposableEffect(key1 = isDeleteEnable) {
                if (isDeleteEnable) {
                    showToast(context, "松手后删去!")
                }
                onDispose {}
            }
            // 省掉部分
            AnimatedVisibility() {
            }
        }
    }
}

假如你读了我的博客《揭秘 Compose 原理》的话,你应该就能了解咱们为什么要这么写了。App 运行期间,Composable 方法是会被重复调用的,咱们需求 isDeleteEnable 来标记用户是否触发了「侧滑删去」的阈值。这儿咱们使用了 derivedStateOf 来优化 Compose 的功能。(之前的博客提到过的哈。)

别的,为了避免 Recompose 的时分一向弹 Toast,咱们使用了 DisposableEffect 这个 SideEffect Handler。这也是「函数式编程」范畴的老概念了。

OK,使命列表完成今后,主页基本上就没什么难度了,FloatingActionButton 的代码我就不贴了。

接下来,咱们来看看「详情页」怎样写。

详情页

有了「主页」的经历今后,「详情页」的完成就简略了,凭借 Scaffold() 咱们能够轻松完成页面的骨架。

@Composable
fun TaskDetailScreen() {
    Scaffold(
        topBar = {
            TaskDetailAppBar()
        },
        content = {
            TaskDetailContent()
        }
    )
}

这次我就不再介绍 TopBar 的完成了,咱们直接看 TaskDetailContent()吧。

2小时入门Jetpack Compose(下)

能够看到,整个「详情页」的结构其实很简略,它只要两排:

  • 榜首排,包括一个 TextField、CheckBox;
  • 第二排,仍是一个 TextField。
@Composable
fun TaskDetailContent() {
    Column() {
        Row(modifier = Modifier.fillMaxWidth()) {
            TextField()
            Checkbox()
        }
        Divider()
        TextField()
    }
}

OK,搞清楚它的结构今后,剩下的便是完善 UI 的细节了:

@Composable
fun TaskDetailContent(
    modifier: Modifier = Modifier,
    title: String,
    onTitleChange: (String) -> Unit,
    description: String,
    onDescriptionChange: (String) -> Unit,
    isDone: Boolean,
    isDoneChange: (Boolean) -> Unit,
) {
    Column(
        modifier = modifier
            .fillMaxSize()
            .background(MaterialTheme.colors.background)
            .padding(all = MEDIUM_PADDING)
    ) {
        Row(modifier = Modifier.fillMaxWidth()) {
            TextField(
                modifier = Modifier
                    .fillMaxWidth()
                    .weight(8F),
                value = title,
                onValueChange = { onTitleChange(it) },
                label = { Text(stringResource(id = R.string.enter_title)) },
                placeholder = { Text(text = stringResource(id = R.string.title)) },
                textStyle = MaterialTheme.typography.body1,
                singleLine = true,
                colors = TextFieldDefaults.textFieldColors(
                    backgroundColor = Transparent
                )
            )
            Checkbox(
                modifier = Modifier.weight(1F),
                checked = isDone,
                onCheckedChange = isDoneChange
            )
        }
        Divider(
            modifier = Modifier.height(SMALL_PADDING),
            color = MaterialTheme.colors.background
        )
        TextField(
            modifier = Modifier
                .fillMaxWidth()
                .height(200.dp),
            value = description,
            onValueChange = { onDescriptionChange(it) },
            label = { Text(stringResource(id = R.string.enter_description)) },
            placeholder = { Text(text = stringResource(id = R.string.description)) },
            textStyle = MaterialTheme.typography.body1,
        )
    }
}

是不是很简略?

结束语

祝贺你!你现已完成课程的 80% 的内容了。

接下来你需求做的,便是跟我文章里的过程,一行行的敲代码了。读我的博客只需求 10 分钟,只要真正花 2 小时写代码,你才可能:「2 小时入门 Jetpack Compose」

关于源代码,请关注我的大众号“朱涛的自习室”,从菜单里选择“资源–>源码”即可获取。

OK,感谢你的阅读,咱们下次再会?