本文原作者: 朱涛,原文发布于: 朱涛的自习室
在上一篇文章《 2 小时入门 Jetpack Compose (上) 》里,我们已经完成了 Splash 页面的 UI 和动画了。
这篇文章,让我们来完成:「首页」+「详情页」吧。
首页
跟 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() 支持的参数非常多,我们只是用到了它很小的一部分,它不仅支持 TopBar、FloatingActionButton,还支持 Drawer、BottomBar,这些都是开箱即用的,我们只需要做少许配置即可。这也是 XML 无法比拟的。
整个首页的骨架已经完成了,接下来,我们一起看看各个组件如何实现吧。
TopBar
首先,是首页顶部的 TopBar。
如果您只是想在 TopBar 上展示一个静态的 Title 的话,那是非常容易的。不过,这里我增加了一个快捷的「清空任务」操作。
只要用户点击这个「清空任务」的按钮,就会清空当前所有的任务。为了做到这一点,我们就需要传一个回调进来,这样方便数据层的操作。这也是 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 当中实现一个弹窗才行。
在从前的 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。简单的几行代码,我们就可以实现一些炫酷的效果。比如,这个进场的动效。
如果让您在 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() 即可。
不过,我们怎么会满足于这种默认效果呢?
让我们来做个炫酷的「侧滑动效」吧~
这个效果看着好像挺麻烦,但这在 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() {
- }
- }
- }
- }
App 运行期间,Composable 方法是会被反复调用的,我们需要 isDeleteEnable 来标记用户是否触发了「侧滑删除」的阈值。这里我们使用了 derivedStateOf 来优化 Compose 的性能。
另外,为了防止 Recompose 的时候一直弹 Toast,我们使用了 DisposableEffect 这个 SideEffect Handler。这也是「函数式编程」领域的老概念了。
OK,任务列表完成以后,首页基本上就没什么难度了,FloatingActionButton 的代码我就不贴了。
接下来,我们来看看「详情页」怎么写。
详情页
有了「首页」的经验以后,「详情页」的实现就简单了,借助 Scaffold() 我们可以轻松实现页面的骨架。
- @Composable
- fun TaskDetailScreen() {
- Scaffold(
- topBar = {
- TaskDetailAppBar()
- },
- content = {
- TaskDetailContent()
- }
- )
- }
这次我就不再介绍 TopBar 的实现了,我们直接看 TaskDetailContent() 吧。
可以看到,整个「详情页」的结构其实很简单,它只有两排:
第一排,包含一个 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」!
长按右侧二维码
查看更多开发者精彩分享
"开发者说·DTalk" 面向中国开发者们征集 Google 移动应用 (apps & games) 相关的产品/技术内容。欢迎大家前来分享您对移动应用的行业洞察或见解、移动开发过程中的心得或新发现、以及应用出海的实战经验总结和相关产品的使用反馈等。我们由衷地希望可以给这些出众的中国开发者们提供更好展现自己、充分发挥自己特长的平台。我们将通过大家的技术内容着重选出优秀案例进行谷歌开发技术专家 (GDE) 的推荐。
点击屏末 | 阅读原文 | 即刻报名参与 "开发者说·DTalk"