大话Compose炼体(1)-先一餐吃3碗饭

1,858 阅读8分钟

前言

炼体系列的文章是针对有一定Compose基础的同学阅读和学习的,主要是结合Compose基础知识,实现各种常用界面,一般不会再去涉及到基础理论知识了,重点在如何实现上。

如果你对Compose感兴趣但是是一个新手的话,建议先掌握了Compose编程思想,搞懂重组,状态,附带效应等基础知识再来阅读会更好。基础入门的系列文章建议先看看筑基系列和过渡系列。

大话Compose筑基(1) - 掘金 (juejin.cn)
大话Compose筑基(2) - 掘金 (juejin.cn)
大话Compose筑基(3) - 掘金 (juejin.cn)
大话Compose筑基(4) - 掘金 (juejin.cn)
大话Compose筑基(5) - 掘金 (juejin.cn)


大话Compose过渡篇(1) - 掘金 (juejin.cn)
大话Compose过渡篇(2) - 掘金 (juejin.cn)

假设我们有个全新项目,准备用Compose的方式来实现页面。在开始前是不是会有很多疑问❓例如:

  • 如何遵循app单Activity的架构理念?
  • Fragment还需要么?
  • 生命周期怎么控制?
  • 协程如何跟生命周期绑定?
  • 页面间如何传值?
  • 页面如何跳转?
  • viewmodel如何使用?
  • 依赖注入如何使用?
  • 页面如何兼容xml?
  • 等等更多

👌,这些现在都不用管!完全不用管!这些疑问在后面自然而然就都能找到答案了。所以先放下这些疑问,大胆的迈出第一步才是关键!

如果你现在正在看什么Fragment最新api、databinding爽歪歪、recycleview相关之类的文章的话,建议你在系统学习Compose的时候先停一下,因为这些内容并不会对你现在的Compose学习有帮助反而会禁锢你的思想。

工具

基础布局字典
M3组件字典
组件演示和规范
以上3个链接包含了常用的布局和组件的使用介绍以及展示,有时间的可以先学习了解作为储备,没有时间的可以在需要用到的时候再去查询学习。

开始

接着上面,假设我们要用Compose开始一个全新的项目。而大部分android app都有类似如下图的首页设计。包含顶部的top app bar(顶部栏),中间的主要内容 (例如列表),底部的navigation bar(导航栏),侧边的navigation drawer(侧面导航抽屉),右下角的FAB(悬浮按钮),不是常驻可见的可能还包括底部显示的snackbar和bottom sheet这类的控件。

在xml方式下,我们可以在xml布局文件里面去选择用相应的布局和控件去嵌套组合出这样的一个页面。然后再通过id去获取相应的view去实现相关数据填充,以及交互。哪怕再不写任何逻辑的情况下,v层的代码行数估计都不少了。

既然大部分app都有会有这样一个差不多的主页设计,因此Compose提供了一个可自定义的Scaffold(首页脚手架)帮助我们快速实现以上功能。

就像刚刚介绍到的,首页会用到的布局和组件还是有不少的。所以炼体就从实现首页的Scaffold开始,作为我们炼体的起点,通过Scaffold来一个一个的展开一些布局和控件的具体使用。

Scaffold

简单看一下Scaffold源码,可以看到Scaffold本身也是一个组合函数,通过在Surface布局下的ScaffoldLayout组合函数实现类包含fab,topbar,bottombar,snackbar等控件的测量和绘制。目前我们知道这些就够了。

@ExperimentalMaterial3Api
@Composable
fun Scaffold(
    modifier: Modifier = Modifier,
    topBar: @Composable () -> Unit = {},
    bottomBar: @Composable () -> Unit = {},
    snackbarHost: @Composable () -> Unit = {},
    floatingActionButton: @Composable () -> Unit = {},
    floatingActionButtonPosition: FabPosition = FabPosition.End,
    containerColor: Color = MaterialTheme.colorScheme.background,
    contentColor: Color = contentColorFor(containerColor),
    contentWindowInsets: WindowInsets = ScaffoldDefaults.contentWindowInsets,
    content: @Composable (PaddingValues) -> Unit
) {
    Surface(modifier = modifier, color = containerColor, contentColor = contentColor) {
        ScaffoldLayout(
            fabPosition = floatingActionButtonPosition,
            topBar = topBar,
            bottomBar = bottomBar,
            content = content,
            snackbar = snackbarHost,
            contentWindowInsets = contentWindowInsets,
            fab = floatingActionButton
        )
    }
}

topBar

topBar经常用top app bar填充,例子我们也用它来演示。现在假设我们完全不知道什么是top app bar,我们该如何做呢?还记得前面的工具里面的三个链接吧?首先通过组件展示的链接来看看什么是top app bar,点击查看什么是Top app bar
可以看到这样的一张图 image.png
通过这图是不是直观的知道了什么是top app bar?感兴趣的话可以再把其他内容阅读一下,这样我们可以从设计的角度了解更多的使用规范等内容。但是这里我们通过这张图知道了top app bar有4种样式就够了。

然后我们要知道如何使用top app bar,这时候通过M3组件字典链接去搜top app bar,可以看到如下内容

image.png

正好对应了刚提到的4种样式的对应类。点击后跳转到相应的代码使用介绍。

CenterAlignedTopAppBar为例
image.png

然后我们代码去具体实现一下如下:

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    WindowCompat.setDecorFitsSystemWindows(window, false)
    setContent {
        val systemUiController = rememberSystemUiController()
        val useDarkIcons = !isSystemInDarkTheme()

        DisposableEffect(systemUiController, useDarkIcons) {
            systemUiController.setSystemBarsColor(
                color = Color.Transparent,
                darkIcons = useDarkIcons,
            )
            onDispose {}
        }
        WaTheme {
            Scaffold(
                topBar = {
                    CenterAlignedTopAppBar(
                        title = {
                            Text(text = "首页")
                        },
                        navigationIcon = {
                            IconButton(onClick = { /*TODO*/ }) {
                                Icon(imageVector = Filled.Menu, contentDescription = "菜单")
                            }
                        },
                        actions = {
                            IconButton(onClick = { /* doSomething() */ }) {
                                Icon(
                                    imageVector = Filled.Person,
                                    contentDescription = "Localized description",
                                )
                            }
                        },
                    )
                },
            ) { padding ->
                padding
            }
        }

    }
}

还是使用过渡篇我们生成的主题样式,运行后页面如图:,这里没有处理左边导航按钮和右边动作按钮的点击事件,下面先来实现点击导航按钮弹出侧边导航抽屉的操作。

后面提到的组件或者布局就不会再重复如何通过工具了解和使用的步骤嘞,不知道的时候请照着top app bar 的例子自主查询了解学习。

NavigationDrawer

使用侧边导航抽屉组件需要注意一下,新版的compose m3库已经把这个组件的优先级提高了,而官方文档没有相应的更新。之前用过的xdm应该知道,之前要使用它是放在Scaffold里面的drawerContent{}槽内,但是新版发现已经没有这个槽了。通过源码发现,这个组件现在不以槽的形式出现了。而是可以不通过Scaffold直接实现NavigationDrawer组件,或者在NavigationDrawer的content{}块里面嵌套Scaffold,又或者在Scaffold的content代码块里面嵌套NavigationDrawer

注意!前两者是让抽屉在content{}块里面的页面的上层(抽屉拉出的时候会盖住topbar等),而后者会让抽屉在topBar,bottomBar的下层也就是跟Scaffold的content{}的页面平层(抽屉拉出会被topbar等遮盖)。通常我们都是采用前两者的实现方式,特殊情况会考虑最后一种方式。

现在我们把WaTheme下的代码改动一下,实现点击导航按钮弹出侧边导航抽屉的操作,看看效果

代码如下

WaTheme {
    val scope = rememberCoroutineScope()
    val drawerState = rememberDrawerState(DrawerValue.Closed)
    val items = listOf(Icons.Default.Favorite, Icons.Default.Face, Icons.Default.Email)
    val selectedItem = remember { mutableStateOf(items[0]) }
    ModalNavigationDrawer(
        drawerState = drawerState,
        drawerContent = {
            ModalDrawerSheet(modifier = Modifier.fillMaxWidth(0.8f)) {
                Spacer(Modifier.height(12.dp))
                Text(
                    text = "标题",
                    Modifier.padding(28.dp),
                    style = MaterialTheme.typography.headlineSmall,
                )
                items.forEachIndexed { index, item ->
                    when (index) {
                        0 -> {
                            NavigationDrawerItem(
                                icon = { Icon(item, contentDescription = null) },
                                label = { Text("收藏文章") },
                                selected = item == selectedItem.value,
                                onClick = {
                                    scope.launch { drawerState.close() }
                                    selectedItem.value = item
                                },
                                badge = {
                                    Text(text = "123篇")
                                },
                                modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
                            )
                        }

                        1 -> {
                            NavigationDrawerItem(
                                icon = { Icon(item, contentDescription = null) },
                                label = { Text("粉丝") },
                                selected = item == selectedItem.value,
                                onClick = {
                                    scope.launch { drawerState.close() }
                                    selectedItem.value = item
                                },
                                badge = {
                                    Text(text = "3人")
                                },
                                modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
                            )
                        }
                        else -> {
                            NavigationDrawerItem(
                                icon = {
                                    BadgedBox(
                                        badge = {
                                            Badge {
                                                val badgeNumber = "8"
                                                Text(
                                                    badgeNumber,
                                                    modifier = Modifier.semantics {
                                                        contentDescription =
                                                            "$badgeNumber new notifications"
                                                    },
                                                )
                                            }
                                        },
                                    ) {
                                        Icon(item, contentDescription = null)

                                    }
                                },
                                label = { Text("未读邮件") },
                                selected = item == selectedItem.value,
                                onClick = {
                                    scope.launch { drawerState.close() }
                                    selectedItem.value = item
                                },

                                modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
                            )
                        }
                    }

                }
            }
        },
        content = {
            // icons to mimic drawer destinations
            Scaffold(
                topBar = {
                    CenterAlignedTopAppBar(
                        title = {
                            Text(text = "首页")
                        },
                        navigationIcon = {
                            IconButton(
                                onClick = {
                                    scope.launch { drawerState.open() }

                                },
                            ) {
                                Icon(
                                    imageVector = Filled.Menu,
                                    contentDescription = "菜单",
                                )
                            }
                        },
                        actions = {
                            IconButton(
                                onClick = {
                                    Toast.makeText(
                                        this@MainActivity,
                                        "action icon clicked",
                                        Toast.LENGTH_SHORT,
                                    ).show()
                                },
                            ) {
                                Icon(
                                    imageVector = Filled.Person,
                                    contentDescription = "Localized description",
                                )
                            }
                        },
                    )
                },
            ) { _ ->

            }

        },
    )

}

可以看到ModalNavigationDrawer的drawerContent{}块里面放ModalDrawerSheet来实现了抽屉内容,ModalDrawerSheet源码简单看了后发现是通过一个Column垂直的列表布局来实现的,所以我们可以在里面通过其他的组件+NavigationDrawerItem来实现我们想要的抽屉内容。NavigationDrawerItem里面的labeliconbadge都是可组合项的函数类型参数,所以我们在这里可以进一步的自定义抽屉里面的每一个Item。

bottomBar

bottomBar通常用bottom app bar或者navigation bar填充,我们今天选择用navigation bar来演示。

Scaffold下面加上下面的代码

bottomBar = {
    NavigationBar {
        navigationBarItems.forEachIndexed { index, item ->
            NavigationBarItem(
                icon = { Icon(Icons.Filled.Favorite, contentDescription = item) },
                label = { Text(item) },
                selected = navigationBarSelectedItem == index,
                onClick = { navigationBarSelectedItem = index }
            )
        }
    }
}

效果如图

接下来我们实现点击top app bar 右边的动作按钮弹出snackbar

snackBar

首先改动top app bar的action{}块下的点击事件

actions = {
    IconButton(
        onClick = {
            //showSnackbar是挂起函数,用scope开启一个协程运行
            scope.launch {
                //返回一个结果
                val showSnackbar =
                    snackbarHostState.showSnackbar(
                        "当前没有用户登录,是否登录?",
                        "确定",
                        true,
                    )
                 //通过结果条件触发后续操作
                when (showSnackbar) {
                    Dismissed -> {
                        Toast.makeText(
                            this@MainActivity,
                            "取消后隐藏snackbar",
                            Toast.LENGTH_SHORT,
                        ).show()
                    }
                    ActionPerformed -> {
                        Toast.makeText(
                            this@MainActivity,
                            "跳转到登录页面",
                            Toast.LENGTH_SHORT,
                        ).show()
                    }
                }

            }
        },
    ) {
        Icon(
            imageVector = Filled.Person,
            contentDescription = "Localized description",
        )
    }
}

在WaTheme{}下申明状态

val snackbarHostState = remember { SnackbarHostState() }

Scaffold{}下给snackbarHost赋值

snackbarHost = {
    SnackbarHost(hostState = snackbarHostState)
}

运行后snackbar可以通过点击icon button显示了,但是这里有个问题。如果我们在snackbar显示时,多次点击icon button,会启动多个启程,在当前snackbar的协程结束后会进入下一个协程,页面又会弹出snackbar,这个显然是不符合我们预期的。

优化思路也很简单,就是在click的时候先判断snackbar是否显示中,如果显示中就隐藏,否则就显示。snackbar的显隐状态我们可以通过snackbarHostState.currentSnackbarData是否为null来判断,如果snackbar当前没有显示,snackbarHostState.currentSnackbarData==null,反之!=null。

给代码加上判断

scope.launch {
    if (snackbarHostState.currentSnackbarData == null) {
        val showSnackbar =
            snackbarHostState.showSnackbar(
                "当前没有用户登录,是否登录?",
                "确定",
                true,
            )
        when (showSnackbar) {
            Dismissed -> {
                Toast.makeText(
                    this@MainActivity,
                    "取消后隐藏snackbar",
                    Toast.LENGTH_SHORT,
                ).show()
            }
            ActionPerformed -> {
                Toast.makeText(
                    this@MainActivity,
                    "跳转到登录页面",
                    Toast.LENGTH_SHORT,
                ).show()
            }
        }
    }else{
        snackbarHostState.currentSnackbarData?.dismiss()
    }

}

👌,再运行就符合预期了。

FAB

FAB的实现比较简单。需要知道的是Compose有4种FAB,分别SmallFABFABLargeFABExtendedFAB根据场景选择使用。另外Compose M3已经不再支持之前M2的那种Docker方式放置FAB了。

floatingActionButton = {
    FloatingActionButton(
        onClick = { /* do something */ },
    ) {
        Icon(Icons.Filled.Add, "Localized description")
    }
}

最后我们通过添加fab的点击来控制bottom sheet的显隐。

bottomSheet

完整代码

        setContent {

            val systemUiController = rememberSystemUiController()
            val useDarkIcons = !isSystemInDarkTheme()

            DisposableEffect(systemUiController, useDarkIcons) {
                systemUiController.setSystemBarsColor(
                    color = Color.Transparent,
                    darkIcons = useDarkIcons,
                )
                onDispose {}
            }
            WaTheme {
                val scope = rememberCoroutineScope()
                val drawerState = rememberDrawerState(DrawerValue.Closed)
                //申明一个状态控制bottom sheet可组合项的生命周期
                var openBottomSheet by rememberSaveable { mutableStateOf(false) }
                //申明一个状态来控制bottom sheet是否半展开
                var skipHalfExpanded by remember { mutableStateOf(false) }
                //申明一个sheet状态控制bottom sheet显隐
                val bottomSheetState = rememberSheetState(skipHalfExpanded)

                val items = listOf(Icons.Default.Favorite, Icons.Default.Face, Icons.Default.Email)
                val selectedItem = remember { mutableStateOf(items[0]) }

                var navigationBarSelectedItem by remember { mutableStateOf(0) }
                val navigationBarItems = listOf("首页", "发现", "喜欢", "我")
                val snackbarHostState = remember { SnackbarHostState() }

                ModalNavigationDrawer(
                    drawerState = drawerState,
                    drawerContent = {
                        ModalDrawerSheet(modifier = Modifier.fillMaxWidth(0.8f)) {
                            Spacer(Modifier.height(12.dp))
                            Text(
                                text = "标题",
                                Modifier.padding(28.dp),
                                style = MaterialTheme.typography.headlineSmall,
                            )
                            items.forEachIndexed { index, item ->
                                when (index) {
                                    0 -> {
                                        NavigationDrawerItem(
                                            icon = { Icon(item, contentDescription = null) },
                                            label = { Text("收藏文章") },
                                            selected = item == selectedItem.value,
                                            onClick = {
                                                scope.launch { drawerState.close() }
                                                selectedItem.value = item
                                            },
                                            badge = {
                                                Text(text = "123篇")
                                            },
                                            modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
                                        )
                                    }

                                    1 -> {
                                        NavigationDrawerItem(
                                            icon = { Icon(item, contentDescription = null) },
                                            label = { Text("粉丝") },
                                            selected = item == selectedItem.value,
                                            onClick = {
                                                scope.launch { drawerState.close() }
                                                selectedItem.value = item
                                            },
                                            badge = {
                                                Text(text = "3")
                                            },
                                            modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
                                        )
                                    }
                                    else -> {
                                        NavigationDrawerItem(
                                            icon = {
                                                BadgedBox(
                                                    badge = {
                                                        Badge {
                                                            val badgeNumber = "8"
                                                            Text(
                                                                badgeNumber,
                                                                modifier = Modifier.semantics {
                                                                    contentDescription =
                                                                        "$badgeNumber new notifications"
                                                                },
                                                            )
                                                        }
                                                    },
                                                ) {
                                                    Icon(item, contentDescription = null)

                                                }
                                            },
                                            label = { Text("未读邮件") },
                                            selected = item == selectedItem.value,
                                            onClick = {
                                                scope.launch { drawerState.close() }
                                                selectedItem.value = item
                                            },

                                            modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
                                        )
                                    }
                                }

                            }
                        }
                    },
                    content = {

                        Scaffold(
                            topBar = {
                                CenterAlignedTopAppBar(
                                    title = {
                                        Text(text = "首页")
                                    },
                                    navigationIcon = {
                                        IconButton(
                                            onClick = {
                                                scope.launch { drawerState.open() }

                                            },
                                        ) {
                                            Icon(
                                                imageVector = Filled.Menu,
                                                contentDescription = "菜单",
                                            )
                                        }
                                    },
                                    actions = {
                                        IconButton(
                                            onClick = {
                                                scope.launch {
                                                    if (snackbarHostState.currentSnackbarData == null) {
                                                        val showSnackbar =
                                                            snackbarHostState.showSnackbar(
                                                                "当前没有用户登录,是否登录?",
                                                                "确定",
                                                                true,
                                                            )
                                                        when (showSnackbar) {
                                                            Dismissed -> {
                                                                Toast.makeText(
                                                                    this@MainActivity,
                                                                    "取消后隐藏snackbar",
                                                                    Toast.LENGTH_SHORT,
                                                                ).show()
                                                            }
                                                            ActionPerformed -> {
                                                                Toast.makeText(
                                                                    this@MainActivity,
                                                                    "跳转到登录页面",
                                                                    Toast.LENGTH_SHORT,
                                                                ).show()
                                                            }
                                                        }
                                                    } else {
                                                        snackbarHostState.currentSnackbarData?.dismiss()
                                                    }

                                                }
                                            },
                                        ) {
                                            Icon(
                                                imageVector = Filled.Person,
                                                contentDescription = "Localized description",
                                            )
                                        }
                                    },
                                )
                            },
                            bottomBar = {
                                NavigationBar {
                                    navigationBarItems.forEachIndexed { index, item ->
                                        NavigationBarItem(
                                            icon = {
                                                Icon(
                                                    Icons.Filled.Favorite,
                                                    contentDescription = item,
                                                )
                                            },
                                            label = { Text(item) },
                                            selected = navigationBarSelectedItem == index,
                                            onClick = { navigationBarSelectedItem = index },
                                        )
                                    }
                                }
                            },
                            snackbarHost = {
                                SnackbarHost(hostState = snackbarHostState)
                            },
                            floatingActionButton = {
                                FloatingActionButton(
                                    onClick = {
                                         //通过fab的点击改变状态,触发重组时bottom sheet进入组合或者退出组合的逻辑
                                        openBottomSheet = !openBottomSheet
                                    },
                                ) {
                                    Icon(Icons.Filled.Add, "Localized description")
                                }
                            },
                        ) { _ ->
                                Row(
                                    Modifier.fillMaxSize().toggleable(
                                        value = skipHalfExpanded,
                                        role = Role.Checkbox,
                                        onValueChange = { checked ->
                                            skipHalfExpanded = checked
                                        },
                                    ),
                                    horizontalArrangement = Arrangement.Center,
                                    verticalAlignment = Alignment.CenterVertically,
                                ) {
                                    Checkbox(
                                        checked = skipHalfExpanded,
                                        onCheckedChange = null,
                                    )
                                    Spacer(Modifier.width(16.dp))
                                    Text("跳过半展开状态")
                                }


                            if (openBottomSheet) {
                                ModalBottomSheet(
                                    //bottom sheet显示时点击外部回调,改变状态让bottom sheet退出组合,实现隐藏
                                    onDismissRequest = { openBottomSheet = false },
                                    sheetState = bottomSheetState,
                                ) {
                                    Row(
                                        Modifier.fillMaxWidth(),
                                        horizontalArrangement = Arrangement.Center,
                                    ) {
                                        Button(
                                            //注意:这个地方很关键,如果我们在onDismissRequest外处理bottom sheet的隐藏,
                                            // hide后必须要改变影响触发bottom sheet组合的状态(openBottomSheet)
                                            //hide后一定还要openBottomSheet =false,这样之前的BottomSheet就会退出组合
                                            //实现真正意义上的隐藏(不然组合项的布局仍然会覆盖整个屏幕)
                                            onClick = {
                                                scope.launch { bottomSheetState.hide() }
                                                    .invokeOnCompletion {
                                                        if (!bottomSheetState.isVisible) {
                                                            openBottomSheet = false
                                                        }
                                                    }
                                            },
                                        ) {
                                            Text("隐藏bottom sheet")
                                        }
                                    }
                                    LazyColumn {
                                        items(50) {
                                            ListItem(
                                                headlineText = { Text("Item $it") },
                                                leadingContent = {
                                                    Icon(
                                                        Icons.Default.Favorite,
                                                        contentDescription = "Localized description",
                                                    )
                                                },
                                            )
                                        }
                                    }
                                }
                            }

                        }

                    },
                )

            }

        }

我们来看一下整体效果
)

小结

本篇通过借助Scaffold来快速实现一个首页,在过程中举例展示了一些常见布局和组件的使用,并且特意以top app bar为例示范了如何通过工具文档来自我探索学习

授人以鱼不如授人以渔,Compose M3的组件和布局不少,版本也比较靠前,每次迭代的变动还是挺大的,所以一个一个得去介绍去分析,那是浪费我们大家的精力和时间。主要的是我们要有学习能力和探索精神,前期需要用到哪个布局组件就去学习它,实现它,这样积累一段时间后,进步是很快的。如果永远都是看别人写,听别人说,牛逼的也永远是别人不是自己。

强烈建议花个半天时间,跟着敲一下,基础有不懂得去看看之前的筑基篇或者评论留言。一天下来你会发现 你真的变强了~