在前一篇笔记中,我们知道了 Compose 布局的一些基本知识,这篇笔记就来详细看看 Compose 布局吧!还有些 Compose 其他的知识,根据官方的实例,我们边看边说。
1. Compose 布局方式
Android 目前的布局 Layout 有许多:LinearLayout 线性布局、RelativeLayout 相对布局、ConstraintLayout 约束布局、FrameLayout 帧布局、TableLayout 表格布局、AbsoluteLayout 绝对布局、GridLayout 网格布局 7 种。后面的几种基本上用的很少了,而 Compose 的布局方式总共有三种:Column 纵向排列布局、Row 横向排列布局、Box 堆叠排列布局。先来个简单的例子:
// code 1
@Composable
fun PhotographerCard() {
Column {
Text("小明", fontWeight = FontWeight.Bold)
CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
Text("3 分钟前", style = MaterialTheme.typography.body2)
}
}
}
注意到在展示第二行文本的时候,外面包了一层 CompositionLocalProvider 方法,这个是干嘛用的?要想知道这个,就必须先知道 CompositionLocal 是什么了。
1.1 CompositionLocal 用法简介
CompositionLocal 类位于 androidx.compose.runtime 包下,总的来说是用于在 composition 树中共享变量的值。在 Compose 构建的 composition 树中,如果需要将顶层的 Composable 函数中的某个变量传递到最底层的 Composable 函数,通常最简单有效的方法就是:1)定义一个全局变量,通过全局变量传值;2)中间层的 Composable 函数添加一个形参,层层传递。
但是这两种方式都不太优雅,尤其是嵌套过深,或者数据比较敏感,不想暴露给中间层的函数时,这种情况下,就可以使用 CompositionLocal 来隐式的将数据传递给所需的 composition 树节点。
CompositionLocal 在本质上就是分层的,它可以将数据限定在以某个 Composable 作为根结点的子树中,而且数据默认会向下传递,当然,当前子树中的某个 Composable 函数可以对该 CompositionLocal 的数据进行覆盖,从而使得新值会在这个 Composable 层级中继续向下传递。举个栗子:
// code 2
// compositionLocalOf 方法可以创建一个 CompositionLocal 实例
val ActiveUser = compositionLocalOf {
// 设置默认值
User("小明","3分钟")
// 如果无须默认值,也可设置错误信息
// error("No active user found!")
}
@Composable
fun PhotographerCard() {
Column {
val user = ActiveUser.current // 通过 current 方法取出当前值
Text(user.name, fontWeight = FontWeight.Bold)
CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
Text(user.lastActiveTime, style = MaterialTheme.typography.body2)
}
// 通过 providers 中缀表达式可以重新对 CompositionLocal 实例赋值
CompositionLocalProvider(ActiveUser provides User("小红", "5分钟前")) {
val newUser = ActiveUser.current
Text(newUser.name, fontWeight = FontWeight.Bold)
CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
Text(newUser.lastActiveTime, style = MaterialTheme.typography.body2)
}
}
}
}
data class User(
val name: String,
val lastActiveTime: String
)
再说回官方栗子,官方栗子是使用 CompositionLocalProvider 对 LocalContentAlpha 进行了重新赋值,对色值的透明度做了调整。查看源码会发现,在 ContentAlpha.kt 中将 LocalContentAlpha 同样使用了 compositionLocalOf 方法设置了它的默认值为 1f,而在这里就重新赋值为 0.74f(ContentAlpha.medium)了,感兴趣的同学可以自己看下~
再说回布局,上面只用到 Column,可以将元素纵向排列;Row 则可以将元素横向进行排列。在官方栗子中还用到了 Surface。
// code 3
@Composable
fun PhotographerCard() {
Row {
Surface(
modifier = Modifier.size(50.dp), // 设置大小
shape = CircleShape, // 设置形状
color = MaterialTheme.colors.onSurface.copy(alpha = 0.2f) // 设置色值
) {
// 加载网络图片逻辑
}
Column {
val user = ActiveUser.current // 通过 current 方法取出当前值
Text(user.name, fontWeight = FontWeight.Bold)
CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
Text(user.lastActiveTime, style = MaterialTheme.typography.body2)
}
}
}
}
1.2 Surface 用法
Surface 位于 androidx.compose.material 包中,很显然它是 Material Design 风格的,可以将它理解为一个容器,我们可以设置容器的高度(带阴影效果)、Shape形状、Background背景等。举个栗子说明会更直观:
// code 4
@Composable
fun SurfaceShow() {
Surface(
shape = RoundedCornerShape(6.dp),
border = BorderStroke(0.5.dp, Color.Green), // 边框
elevation = 10.dp, // 高度
modifier = Modifier
.padding(10.dp), // 外边距
// color = Color.Black, // 背景色
contentColor = Color.Blue,
) {
Surface(
modifier = Modifier
.clickable { } // 点击事件在 padding 前,则此padding为内边距
.padding(10.dp),
contentColor = Color.Magenta // 会覆盖之前 Surface 设置的 contentColor
) {
Text(text = "This is a SurfaceDemo~")
}
}
}
在这里实现了一个带边框圆角和阴影的按钮。Surface 的功能主要有:
- 裁剪,根据 shape 属性描述的形状进行裁剪;
- 高度,根据 elevation 属性设置容器平面的高度,让人看起来有阴影的效果;
- 边框,根据 border 属性设置边框的粗细以及色值;
- 背景,Surface 在 shape 指定的形状上填充颜色。这里会比较复杂一点,如果颜色是 Colors.surface,则会将 LocalElevationOverlay 中设置的 ElevationOverlay 进行叠加,默认情况下只会发生在深色主题中。覆盖的颜色取决于这个 Surface 的高度,以及任何父级 Surface 设置的 LocalAbsoluteElevation。这可以确保一个 Surface 的叠加高度永远不会比它的祖先低,因为它是所有先前 Surface 的高度总和。
- 内容颜色,根据 contentColor 属性给这个平面的内容指定一个首选色值,这个色值会被文本和图标组件以及点击态作为默认色值使用。当然可以被子节点设置的色值覆盖。
1.3 Modifier 简单用法
Modifier 属性用法太多了,设置 padding、click 等等,布局排版的许多工作都是由它来完成的。
// code 5
@Composable
fun PhotographerCard() {
Row (
modifier = Modifier.fillMaxWidth() // 相当于 width = match_parent
.padding(10.dp) // 外边距为 10dp
.clip(RoundedCornerShape(6.dp)) // 设置圆角
.clickable { } // 点击事件
.padding(16.dp) // 内边距为 16dp
){
Surface(
modifier = Modifier.size(50.dp), // 设置大小
shape = CircleShape, // 设置形状
color = MaterialTheme.colors.onSurface.copy(alpha = 0.2f) // 设置色值
) {
// 加载网络图片逻辑
}
Column(
modifier = Modifier.padding(start = 8.dp) // 单独设置 左边距
.align(Alignment.CenterVertically) // 设置里面的子元素竖直方向上居中分布
) {
val user = ActiveUser.current // 通过 current 方法取出当前值
Text(user.name, fontWeight = FontWeight.Bold)
CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
Text(user.lastActiveTime, style = MaterialTheme.typography.body2)
}
}
}
}
细心的同学发现了,modifier 只能设置 padding,没有 margin 属性。在 clickable 前后各有一个 padding,前者就是设置的外边距,后者就是内边距。所以,在 Modifier 中设置 padding 的次序很重要。
2. Scaffold 脚手架用法
Compose 自带 Material 组件用于快速开发一个符合 Material Design 标准的 APP,最顶端的组件是 Scaffold,咦?是不是又看到了 Flutter 的影子?
不得不说,Google 的工程师真的很了解建筑学,连起名都借用了建筑学的概念,这个 Scaffold 组件的功能就跟它的翻译一样,用于构建一个基本的 Material Design 布局框架。它提供了诸如 TopAppBar、BottomAppBar、FloatingActionButton 和 Drawer 等常见的组件。
// code 6
@Composable
fun LayoutInCompose() {
var selectedItem by remember { mutableStateOf(0) }
val navItems = listOf("Songs", "Artists", "Playlists")
Scaffold(
topBar = { // topBar 属性用于设置 AppBar
TopAppBar(
title = { // 可设置标题
Text(text = "LayoutInCompose")
},
actions = { // 设置 AppBar 上的按钮 Button
IconButton(onClick = { /*TODO*/ }) {
// Icon 系统为我们提供了许多常见的 Icon
Icon(Icons.Filled.Favorite, contentDescription = null)
}
}
)
},
bottomBar = { // bottomBar 可用于设置 BottomNavigation
BottomNavigation() {
navItems.forEachIndexed { index, item ->
BottomNavigationItem(
icon = {Icon(Icons.Filled.Face, contentDescription = null)},
label = {Text(item)},
selected = selectedItem == index,
onClick = { selectedItem = index }
)
}
}
}
) {
BodyContent(modifier = Modifier
.padding(it)
.padding(8.dp))
}
}
@Composable
fun BodyContent(modifier: Modifier) {
Column(modifier = modifier) {
Text(text = "Hi there!")
Text(text = "Thanks for watching this")
}
}
可以看出,Scaffold 真的为我们提供了好多组件,这里仅仅举了 TopAppBar 和 BottomNavigation 两个。但在实际中,我们用到的并不多,除非是需要快速上线,没有 UI 设计等等。所以我个人感觉,Scaffold 并不是我们应该掌握的重点,了解即可。
3. List 中布局的使用
在笔记一中,我们见识到了 Compose 使用 LazyColumn 来实现一个可滑动的 List,其实实现一个可滑动的 List 并不需要用到 LazyColumn,只需要用 Column 中的 Modifier.verticalScroll 属性就可以了。看代码:
// code 7
@Composable
fun SimpleList() {
// 使用 rememberScrollState 保存滚动的位置信息
val scrollState = rememberScrollState()
// Modifier.verticalScroll 可添加竖直方向上的滚动属性
// 使用 Column 的 Modifier.verticalScroll 方法确实可以创建一个可滑动的
// List,但是这种方法在开始时就会将所有 item 全部加载,类似于 ScrollView
Column(Modifier.verticalScroll(scrollState)) {
repeat(100) {
Text(text = "Item #$it")
Divider(color = Color.Blue, thickness = 1.5.dp, startIndent = 10.dp)
}
}
}
@Composable
fun BodyContent(modifier: Modifier) {
Column(modifier = modifier) {
Text(text = "Hi there!")
Text(text = "Thanks for watching this")
SimpleList() // 将 List 放在之前的布局中展示出来
}
}
这种实现方法最简单,但是会在页面开始展示时,将列表中所有的 item 加载到内存中,虽然很多 item 都没有显示在屏幕上,这种方法当列表内容很多时,会出现内存占用大的问题。
所以一般是使用 LazyColumn 来展示列表数据,LazyColumn 开始时并不会把所有的列表数据都加载进内存,它会先将展示在屏幕上的列表数据加载进内存,当滑动查看更多列表数据时,才会将这些数据加载到内存中。而且,LazyColumn 在内部已经实现了滑动的逻辑,不需要用 Modifier.verticalScroll 来实现。来看一下例子:
// code 8
@Composable
fun ImageListItem(index: Int) { // 列表 item 布局
// Row 可设置竖直方向上的对齐方式
Row(verticalAlignment = Alignment.CenterVertically) {
Image(
painter = rememberImagePainter(
data = "https://pic.ntimg.cn/20140810/3822951_180850680000_2.jpg"
),
contentDescription = "Test Img",
modifier = Modifier.size(50.dp)
)
Spacer(modifier = Modifier.width(10.dp)) // Spacer 也可设置边距
Text(text = "Item #$index", style = MaterialTheme.typography.subtitle1)
}
}
@Composable
fun ScrollingList() {
val listSize = 100
// 使用 rememberLazyListState 保存滚动的位置
val scrollState = rememberLazyListState()
LazyColumn(state = scrollState) {
items(listSize) {
ImageListItem(index = it)
Divider(color = Color.Blue, thickness = 1.5.dp, startIndent = 10.dp)
}
}
}
列表项比较简单,就一张图一个文案,这里图片加载库使用的是 Coil,使用 Kotlin 协程写的一个图片加载库,感兴趣的可以看看。需要引入 Coil 的依赖:
// build.gradle
implementation 'io.coil-kt:coil-compose:1.3.0'
引入之后就可以使用 code 8 中的 rememberImagePainter 直接将图片链接传给 data 即可。还要记得获取一下网络权限。还可以看到这里图片与文案之间的间隔是用 Spacer 来实现的,当然也可以在 Text 中的 Modifier 属性设置 padding 来实现。
4. ConstraintLayout 约束布局
众所周知,Android View 体系中官方最推荐的布局是约束布局 —— ConstraintLayout,以致于在默认新建布局时就给你初始化成 ConstraintLayout。当然,ConstraintLayout 确实可以解决 View 体系中多层嵌套的问题,那么在 Compose 中也可以使用吗?
答案是肯定的。Compose 中也可以使用 ConstraintLayout,是使用 Row、Column、Box 布局的另一种解决方案。在实现更大的布局以及有许多复杂对齐要求以及布局嵌套过深的场景下,ConstraintLayout 用起来更加顺手。使用前,得引入 Compose 中的 ConstraintLayout 依赖库:
// build.gradle
implementation "androidx.constraintlayout:constraintlayout-compose:1.0.0-alpha07"
在 Compose 中使用 ConstraintLayout 有几点需要注意的:
- ConstraintLayout 中的子元素是通过 createRefs() 或 createRef() 方法初始化声明的,并且每个子元素都会关联一个ConstraintLayout 中的 Composable 组件;
- 子元素之间的约束关系是通过 Modifier.constrainAs() 的 Lambda 表达式来实现的,具体的可以看下面的 code 9;
- 约束关系可以使用 linkTo 或其他约束方法实现;
- parent 是一个默认存在的引用,代表 ConstraintLayout 父布局本身,也是用于子元素的约束关联。
下面是一个简单的例子:
// code 9
@Composable
fun ConstraintLayoutDemo() {
ConstraintLayout {
// 初始化声明两个元素,如果只声明一个,则可用 createRef() 方法
// 这里声明的类似于 View 的 id
val (button, text) = createRefs()
Button(
onClick = {},
// constrainAs() 将 Composable 组件与初始化的引用关联起来
// 关联之后就可以在其他组件中使用并添加约束条件了
modifier = Modifier.constrainAs(button) {
// 熟悉 ConstraintLayout 约束写法的一眼就懂
// parent 引用可以直接用,跟 View 体系一样
top.linkTo(parent.top, margin = 20.dp)
start.linkTo(parent.start, margin = 10.dp)
}
){
Text("Button")
}
Text(text = "Text", Modifier.constrainAs(text) {
top.linkTo(button.bottom, margin = 16.dp)
start.linkTo(button.start)
centerHorizontallyTo(parent) // 摆放在 ConstraintLayout 水平中间
})
}
}
细心的同学可能会有疑问,为啥下面的 Text 设置了父布局水平居中,但是好像是在 Button 宽度的中间呢?这是因为父布局的 ConstraintLayout 的大小默认是尽量小的容纳它的子元素,这跟 wrap_content 一样。可以将开发者选项中的显示布局边界打开看看:
这样就直观多了。要把 Text 放在整个屏幕的水平居中的位置,需要在 ConstraintLayout 中设置 Modifier.fillMaxWidth() 即可。
当然,Compose 版本的 ConstraintLayout 也支持设置使用 guideline、barrier、chain 等。
4.1 Barrier 的用法
先来看看 Barrier 的用法,就是字面意思,给一些子元素设置栅栏,将栅栏两侧的子元素分隔开的作用:
// code 10
@Composable
fun ConstraintLayoutDemo1() {
ConstraintLayout {
val (button1, button2, text) = createRefs()
Button(
onClick = {},
modifier = Modifier.constrainAs(button1) {
top.linkTo(parent.top, margin = 20.dp)
start.linkTo(parent.start, margin = 10.dp)
}
){
Text("Button1")
}
Text(text = "Text文本", Modifier.constrainAs(text) {
top.linkTo(button1.bottom)
// 将 Text 的中心摆放在 button1 右边界的位置
centerAround(button1.end)
})
// 设置一个 button1 和 text 右边的一个栅栏,将两者放在栅栏的左侧
val barrier = createEndBarrier(button1, text)
Button(
onClick = {},
modifier = Modifier.constrainAs(button2) {
top.linkTo(parent.top, margin = 20.dp)
// 将 button2 放在栅栏的右侧
start.linkTo(barrier)
}
) {
Text(text = "button2")
}
}
}
创建栅栏的函数不仅有 createEndBarrier() 方法,类似用法的总结起来有:
- createTopBarrier()、createBottomBarrier() : 创建分隔上下组件的栅栏;
- createStartBarrier()、createEndBarrier() : 创建分隔左右组件的栅栏;
- createAbsoluteLeftBarrier()、createAbsoluteRightBarrier() : 创建分隔左右组件的栅栏,满足国际化的需求。
最后两个是用于国际化适配,因为有些语言是从右到左排列的,如阿拉伯语,所以如果要严格按照左右来区分的话,使用带 Absolute 的方法,这个跟 marginStart 和 marginLeft 概念差不多。此外,创建 Barrier 并设置组件在 Barrier 的相对位置时,需要满足客观逻辑的。不能创建一个分隔左右组件的栅栏,但是我又设置 top.linkTo(barrier) 或 bottom.linkTo(barrier)。这在客观逻辑上就不成立,当然代码也会报错。
4.2 Guideline 的用法
Compose 版本中的 Guideline 用法大同小异,还是先从例子说起:
// code 11
@Composable
fun LargeConstraintLayout() {
ConstraintLayout(Modifier.fillMaxHeight()) {
val text = createRef()
val guideline1 = createGuidelineFromStart(fraction = 0.5f)
Text(text = "This text is very long",
modifier = Modifier.constrainAs(text) {
linkTo(start = guideline1, end = parent.end)
}
)
val text1 = createRef()
val guideline2 = createGuidelineFromTop(fraction = 0.333f)
Text(text = "我距离屏幕上方约三分之一处~",
modifier = Modifier.constrainAs(text1) {
top.linkTo(guideline2)
}
)
}
}
guideline1 设置的是在父布局水平位置 50% 的地方,这里由于 ConstraintLayout 默认尺寸是 wrap_content,所以父布局的宽度会设置为 text 的两倍的宽度,这样就满足了 text 起始位置在父布局的中间,根据图中的布局分界线也可以看出。而 guideline2 是在竖直方向上距离屏幕高度三分之一的位置,需要把父布局的高度设置为屏幕高度才可以实现。
上面的例子只列举了 guideline 根据百分比来设置它的位置,其实也可以根据偏移量来设置。用法总结起来如下列所示:
- createGuidelineFromStart(offset: Dp):根据左侧距离父布局偏移量来设置 guideline 位置
- createGuidelineFromStart(fraction: Float):根据左侧距离父布局的百分比来设置 guideline 位置
- createGuidelineFromAbsoluteLeft(offset: Dp):国际化才使用
- createGuidelineFromAbsoluteLeft(fraction: Float)
- createGuidelineFromEnd(offset: Dp)
- createGuidelineFromEnd(fraction: Float)
- createGuidelineFromAbsoluteRight(offset: Dp)
- createGuidelineFromAbsoluteRight(fraction: Float)
- createGuidelineFromTop(offset: Dp)
- createGuidelineFromTop(fraction: Float)
- createGuidelineFromBottom(offset: Dp)
- createGuidelineFromBottom(fraction: Float)
看着挺多,其实就是上下左右加上国际化的情况。
ConstraintLayout 还有一个特性,就是当它的子元素过大时,ConstraintLayout 默认是可以允许子元素超出屏幕范围的,以上面的例子继续说,当横向的 Text 内容很多时,就会出现 Text 部分内容超出屏幕。。。。上代码:
// code 12
@Composable
fun LargeConstraintLayout() {
ConstraintLayout(Modifier.fillMaxHeight()) {
val text = createRef()
val guideline1 = createGuidelineFromStart(fraction = 0.5f)
// very 单词有 10 个
Text(text = "This text is very very very very very very very very very very long",
modifier = Modifier.constrainAs(text) {
linkTo(start = guideline1, end = parent.end)
}
)
}
}
注意看,Text 文本有 10 个 very,但是只展示出来 8个,而且明显 Text 左边界不是位于屏幕中间位置,所以在默认情况下,ConstraintLayout 允许子元素超出屏幕。怎么做才能达到我们想要的效果?在这里需要设置一下 Text 的 width 宽度的属性为 Dimension.preferredWrapContent。
// code 13
Text(text = "This text is very very very very very very very very very very long",
modifier = Modifier.constrainAs(text) {
linkTo(start = guideline1, end = parent.end)
width = Dimension.preferredWrapContent
}
)
OK, 这个 Dimension 的属性一共有五种:
- preferredWrapContent:布局大小是根据内容所设置,并受布局约束的影响。这个例子中对 Text 右边界做了限制,所以使用这个属性可以控制 Text 右边界只能到达父布局右边界,不能超出屏幕;
- wrapContent:Dimension 的默认值,即布局大小只根据内容所设置,不受约束;
- fillToConstraints:布局大小将展开填充由布局约束所限制的空间。也就是说,这个属性是先看看布局约束所限制的空间有多大,然后再将该子元素填充到这个有约束的空间中;
- preferredValue:布局大小是一个固定值,并受布局约束的影响;
- value:布局大小是一个固定值,不受约束。
此外,Dimension 还可组合设置布局大小,例如:width = Dimension.preferredWrapContent.atLeast(100.dp)
可设置最小布局大小,同样还有 atMost()
可设置最大布局大小等等。
4.3 Chain 的用法
Chain 链,与 xml 中的用法一样,就是将一系列子元素按顺序打包成一行或一列。官方将这个 api 标记为可以改进的状态,可能后续会发生变化。api 只有两个,创建横向和纵向的链:
- createHorizontalChain()
- createVerticalChain()
第一个参数是需要打包在一起的所有子元素的id,第二个参数是链的类型,目前有三种类型:
- Spread:所有子元素平均分布在父布局空间中,是默认类型;
- SpreadInside:第一个和最后一个分布在链条的两端,其余子元素平均分布剩下的空间;
- Packed:所有子元素打包在一起,并放在链条的中间。
代码及效果如下:
// code 14
@Composable
fun ConstraintLayoutChainDemo() {
ConstraintLayout(modifier = Modifier.fillMaxSize()) {
val (box1, box2, box3) = createRefs()
createHorizontalChain(box1,box2,box3, chainStyle = ChainStyle.Spread)
Box(modifier = Modifier.size(100.dp).background(Color.Red).constrainAs(box1){})
Box(modifier = Modifier.size(100.dp).background(Color.Green).constrainAs(box2){})
Box(modifier = Modifier.size(100.dp).background(Color.Blue).constrainAs(box3){})
}
}
chainStyle 设置为 ChainStyle.Spread 的效果:
chainStyle 设置为 ChainStyle.SpreadInside的效果:
chainStyle 设置为 ChainStyle.SpreadInside的效果:
4.4 ConstraintSet 实现动态适配
上面谈论的都是静态设置各种约束布局的情况,没有考虑到横竖屏切换可能导致的布局适配问题。其实 ConstraintLayout 可以传入一个 ConstraintSet 类型的参数,根据这个参数可以设置不同的约束条件,可以进行灵活设置。
// code 15
@Composable
fun DecoupledConstraintLayout() {
BoxWithConstraints() {
val constraints = if (maxWidth
这里横竖屏的布局有所不同,就是通过设置不同的 ConstraintSet 来实现的,如果布局元素很多,可以分为两个 ConstraintSet 对象来分别设置。需要注意的是,ConstraintLayout 中子元素的 layoutId 是通过 Modifier 设置的,需要与 ConstraintSet 的 createRefFor 的参数保持一致。下面是横竖屏的显示效果:
第二篇 Compose 学习笔记终于完成,Compose 的布局你学会了么?欢迎留言交流~
更多内容,欢迎关注公众号:修之竹
或者查看 修之竹的 Android 专辑
参考文献
- https://developer.android.google.cn/codelabs/jetpack-compose-layouts?continue=https%3A%2F%2Fdeveloper.android.google.cn%2Fcourses%2Fpathways%2Fcompose%23codelab-https%3A%2F%2Fdeveloper.android.com%2Fcodelabs%2Fjetpack-compose-layouts#0
- https://developer.android.google.cn/reference/kotlin/androidx/compose/runtime/CompositionLocal
- https://compose.net.cn/design/theme/understanding_material_theme/
- https://compose.net.cn/elements/surface/
- https://developer.android.google.cn/reference/kotlin/androidx/compose/material/package-summary#BottomNavigation(androidx.compose.ui.Modifier,androidx.compose.ui.graphics.Color,androidx.compose.ui.graphics.Color,androidx.compose.ui.unit.Dp,kotlin.Function1))
- 乐翁龙. 《Jetpack Compose – ConstraintLayout》https://blog.csdn.net/u010976213/article/details/111184997
ps. 赠人玫瑰,手留余香。欢迎转发分享加关注,你的认可是我继续创作的精神源泉。
服务器托管,北京服务器托管,服务器租用 http://www.fwqtg.net