Jetpack-Compose 初探——学习笔记1~3 总结

历时两年,Android 团队推出了全新的原生 Android 界面 UI 库——Compose。当然,Compose 也是属于 Jetpack 工具库中的一部分,官方宣称可以简化并加快 Android 上的界面开发,可以用更少的代码去快速打造生动而精彩的应用。1.0 版本就在今年7月底刚刚发布,而且可以在生产环境中使用,前提是 Android Studio 需要升级到 Android Studio Arctic Fox | 2020.3.1 或以上版本。

本次分享主要介绍 Compose 有哪些特点,Compose 中的简单布局以及在项目中实际应用的相关知识。

1. 优势

1)更少的代码。 用更少的代码可实现更加丰富的功能;抛弃 xml,所有代码均用 kotlin 实现,问题追踪更高效。
2)直观。 使用声明式 UI,绑定数据后,UI 更新全部会交给 Compose 处理,使用者不需担心数据与 UI 视图不一致的问题。且支持视图预览,UI开发更直观高效。
3)渲染高效。 Compose 首先会生成整个屏幕,然后仅仅执行必要的更改,会智能跳过那些数据没有发生改变的控件,重新生成已经发生改变的控件,这一过程称之为重组(recomposition)。此外,Compose 布局模型不允许多次测量,保证了渲染效率。
4)兼容性好。 与现有代码兼容,既可View调用Compose,也可Compose调用View;Jetpack常用库如 Navigation、ViewModel等以及 Kotlin 协程都适用。可直接使用 Material Design 组件以及主题,同时还有简明的动画 API 可以让应用更加灵动,体验更好。

2. 配置

三步。1)升级到 Android Studio Arctic Fox | 2020.3.1 或以上版本。 2)添加工具依赖项,参考:https://developer.android.google.cn/jetpack/compose/setup。3)配置相关的环境,参考:https://developer.android.google.cn/jetpack/compose/interop/adding

需要注意的是,Compose 支持的 Android 版本是在 API 21 级别或更高级别。

3. 简单上手

Compose 核心内容就是可组合的函数。 如同它的英文名称一样,将 UI 拆解成一个个可组合在一起的 Composable 函数,方便维护与复用。但是,可组合函数只能在其他的可组合函数的范围内调用。要使函数成为可组合函数,只需在该函数上方添加 @Composable 注解即可。其实可以直接把被 @Composable 注解的函数看成是一个 View。

@Composable 注解可告诉 Compose 编译器:此函数旨在将数据转换为界面。并且生成界面的 Compose 函数不需要返回任何内容,因为它们描述的是所需的屏幕状态,而不是构造界面的组件。

支持预览。 在 Composable 函数上再添加一个 @Preview 注解就可以预览了,限制条件是 @Preview 注解只能修饰一个无参的函数,所以,如果需要预览,就得保证预览的函数无参,或者再用一个无参函数包起来:

// code 1
@Composable
fun Greeting(name: String) {
    Text(text = "Hello $name!")
}

@Preview
@Composable
fun WrapperView() {
    Greeting("Jetpack")
}

Jetpack-Compose 初探——学习笔记1~3 总结_第1张图片
UI 修改可以实时更新查看,无需开启模拟器。

Compose 的 Hello World 代码只需将要显示的 View 换成 Composable 函数,并且方法由 setContentView 换成 setContent 即可:

// code 2
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {    // 设置显示内容,相当于 setContentView
            Greeting("Hello World!")
        }
    }
}

4. Compose 布局

Android 目前的布局 Layout 有许多:LinearLayout 线性布局、RelativeLayout 相对布局、ConstraintLayout 约束布局、FrameLayout 帧布局、TableLayout 表格布局、AbsoluteLayout 绝对布局、GridLayout 网格布局 7 种。后面的几种基本上用的很少了,而 Compose 的布局方式总共有 四种:Column 纵向排列布局、Row 横向排列布局、Box 堆叠排列布局和 ConstraintLayout 约束布局。下面是一个 Compose 实现的简单列表,使用了 Column、Row 以及 Box 三种布局,以及 LazyColumn 列表组件。

列表项结构比较简单,由图片 Image 和文案 Text 组件构成。具体代码如下:

// code 3
    @Composable
    fun ImageListItem(index: Int) {
        Box(
            modifier = Modifier.fillMaxWidth()
                .padding(5.dp)  // 外边距
                .clip(RoundedCornerShape(6.dp)) // 圆角
                .clickable {
                    // 点击事件
                    Toast
                        .makeText(this@MainActivity, "$index 被点击了", Toast.LENGTH_SHORT)
                        .show()
                }
                .padding(5.dp),  // 内边距
            contentAlignment = Alignment.CenterEnd
        ) {
            Image(painter = painterResource(id = R.drawable.ic_item_bg),
                contentDescription = "item bg img",
                modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(5.dp)),
                contentScale = ContentScale.Crop,
                alpha = 0.3f)
                
            Row(verticalAlignment = Alignment.CenterVertically,
                modifier = Modifier
                    .fillMaxWidth()
            ) {
                Surface(
                    modifier = Modifier.size(50.dp),  // 设置大小
                    shape = CircleShape,  // 设置形状
                    color = MaterialTheme.colors.onSurface.copy(alpha = 0.2f)  // 设置色值
                ) {
                    // 加载网络图片逻辑
                    Image(
                        painter = rememberImagePainter(
                            data = "https://pic.ntimg.cn/20140810/3822951_180850680000_2.jpg"
                        ),
                        contentDescription = "dog img",
                        modifier = Modifier.size(50.dp),
                        contentScale = ContentScale.Crop  // 居中裁剪
                    )
                }

                Column(
                    modifier = Modifier
                        .padding(start = 8.dp)  // 单独设置 左边距
                        .align(Alignment.CenterVertically)  // 设置里面的子元素竖直方向上居中分布
                ) {
                    Text("Item #$index", fontWeight = FontWeight.Bold)
                    CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
                        Text("${index}分钟前来过", style = MaterialTheme.typography.body2)
                    }
                }
            }
        }
    }
}

4.1 Box 布局

首先,最外层的是一个 Box 布局包裹,因为需要设置背景图,用到的是 Image 组件,然后是第二层的布局。

注意 Box 布局有许多其他的属性修饰的,其中最主要的就是 Modifier,许多属性都可用 Modifier 进行修饰,例如:size 大小、填充父布局方式、padding 边距、clip 剪切方式、点击事件、滑动事件等等。这里就是设置了内外边距,以及使用 clip 设置圆角、clickable 设置点击事件。

Modifier 属性几乎存在于所有的 Compose 组件中,采用链式调用,设置方便。

Box 布局还可设置里面内容的对齐方式 ContentAlignment,这里设置的是靠右居中对齐。里面子元素如同 FrameLayout 一样,先放置的在最底下,这里就是用于设置背景图。

4.2 Image 组件

Image 组件用于显示图片,主要的几个属性有:painter、contentDescription、modifier、contentScale、alignment、alpha。其中,contentDescription 属性用于辅助功能时提示给用户的描述信息;contentScale 设置拉伸方式,这里设置的是 ContentScale.Crop 居中裁剪;alpha 设置透明度。

painter 属性在这里使用的是 painterResource(R.drawable.ic_item_bg),用于加载 drawable 目录下的图片。如果需要加载网络图片,可以引入 coil 依赖库:

// build.gradle
implementation 'io.coil-kt:coil-compose:1.3.0'

coil 是 Compose 中推荐使用的图片网络加载库,底层采用 Kotlin 协程进行加载。添加了依赖之后,就可以使用 rememberImagePainter 直接将图片链接传给 data 即可。如下代码:

Image(
    painter = rememberImagePainter(
        data = "https://pic.ntimg.cn/20140810/3822951_180850680000_2.jpg"
    ),
    contentDescription = "dog img",
    modifier = Modifier.size(50.dp),
    contentScale = ContentScale.Crop  // 居中裁剪
)

这段代码也是列表项中显示狗头图片的代码,接下来是第二层的布局,使用的是水平方向上进行摆放的 Row 组件。

4.3 Row 布局

类似于 View 体系中的 LinearLayout 线性布局,orientation 方向设置的是 Horizontal 横向。它的属性有:

@Composable
inline fun Row(
    modifier: Modifier = Modifier,
    horizontalArrangement: Arrangement.Horizontal = Arrangement.Start,
    verticalAlignment: Alignment.Vertical = Alignment.Top,
    content: @Composable RowScope.() -> Unit
) {
}

Modifier 属性不用多说;horizontalArrangement 属性是设置排列方向的,默认是 Arrangement.Start,即从左到右进行排列,还能设置为 Arrangement.End 从右到左进行排列。verticalAlignment 属性是设置竖直方向上的对齐方式,默认为 Alignment.Top,即各个子元素头部对齐,还有 Alignment.Bottom 尾部对齐,以及 Alignment.CenterVertically,竖直方向上居中对齐。content 就是设置子元素了。

在这个用例中就是设置了子元素竖直方向居中对齐,宽度尺寸为 fillMaxWidth,相当于 wrap_content。并且水平排列了两个元素,一图和一组文案。Image 被 Surface 组件包裹,主要为了裁剪成圆形。

4.4 Surface 组件

Surface 位于 androidx.compose.material 包中,很显然它是 Material Design 风格的,可以将它理解为一个容器,我们可以设置容器的高度(带阴影效果)、Shape形状、Background背景等。举个栗子说明会更直观:

@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~")
        }
    }
}

Jetpack-Compose 初探——学习笔记1~3 总结_第2张图片
在这里实现了一个带边框圆角和阴影的按钮。Surface 的功能主要有:

  1. 裁剪,根据 shape 属性描述的形状进行裁剪;
  2. 高度,根据 elevation 属性设置容器平面的高度,让人看起来有阴影的效果;
  3. 边框,根据 border 属性设置边框的粗细以及色值;
  4. 背景,Surface 在 shape 指定的形状上填充颜色。这里会比较复杂一点,如果颜色是 Colors.surface,则会将 LocalElevationOverlay 中设置的 ElevationOverlay 进行叠加,默认情况下只会发生在深色主题中。覆盖的颜色取决于这个 Surface 的高度,以及任何父级 Surface 设置的 LocalAbsoluteElevation。这可以确保一个 Surface 的叠加高度永远不会比它的祖先低,因为它是所有先前 Surface 的高度总和。
  5. 内容颜色,根据 contentColor 属性给这个平面的内容指定一个首选色值,这个色值会被文本和图标组件以及点击态作为默认色值使用。当然可以被子节点设置的色值覆盖。

在上述的例子中 Surface 包裹了 Image 组件,对其设置了大小以及形状,并且设置了 Image 组件的底色,底色用于图片未加载成功时的占位。这里底色是先获取了 MaterialTheme 中的 onSurface 色值,然后修改了透明度,这里也可看出,Compose 已经集成了 MaterialTheme 主题。最后就是那一组文案,是使用 Column 组件进行布局的。

4.5 Column 布局

与 Row 组件排列方向不同,也是类似于 View 体系中的 LinearLayout 线性布局,orientation 方向设置的是 Vertical 纵向。它的属性有:

@Composable
inline fun Column(
    modifier: Modifier = Modifier,
    verticalArrangement: Arrangement.Vertical = Arrangement.Top,
    horizontalAlignment: Alignment.Horizontal = Alignment.Start,
    content: @Composable ColumnScope.() -> Unit
)

排列,对齐等与 Row 组件可以对照着看,更容易理解。

4.6 CompositionLocal 用法简介

Column 排列组件中的 Text 组件是用于文案显示,可以看到第二个 Text 被 CompositionLocalProvider 组件包裹:

                Column(
                    modifier = Modifier
                        .padding(start = 8.dp)  // 单独设置 左边距
                        .align(Alignment.CenterVertically)  // 设置里面的子元素竖直方向上居中分布
                ) {
                    Text("Item #$index", fontWeight = FontWeight.Bold)
                    CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
                        Text("${index}分钟前来过", style = MaterialTheme.typography.body2)
                    }
                }

其实这里的 CompositionLocalProvider 是为了修改 Text 的透明度,使之看起来色值更浅。它是将原来的 LocalContentAlpha 默认值由 ContentAlpha.high 改成了 ContentAlpha.medium。用到了 Compose 中 CompositionLocal 的用法 。

CompositionLocal 类位于 androidx.compose.runtime 包下,总的来说是用于在 composition 树中共享变量的值。在 Compose 构建的 composition 树中,如果需要将顶层的 Composable 函数中的某个变量传递到最底层的 Composable 函数,通常最简单有效的方法就是:1)定义一个全局变量,通过全局变量传值;2)中间层的 Composable 函数添加一个形参,层层传递。

但是这两种方式都不太优雅,尤其是嵌套过深,或者数据比较敏感,不想暴露给中间层的函数时,这种情况下,就可以使用 CompositionLocal 来隐式的将数据传递给所需的 composition 树节点。

CompositionLocal 在本质上就是分层的,它可以将数据限定在以某个 Composable 作为根结点的子树中,而且数据默认会向下传递,当然,当前子树中的某个 Composable 函数可以对该 CompositionLocal 的数据进行覆盖,从而使得新值会在这个 Composable 层级中继续向下传递。

通常的用法看下面的代码:

// 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
)

以上就是列表项的实现,整个列表是使用的 LazyColumn 组件实现的。

4.7 LazyColumn 列表组件

可以延迟加载的列表组件,遗憾的是没有回收机制,不然就和 RecyclerView 一样好用了。延迟加载指的是,只会加载此时出现在屏幕中的 item,其他的不用全部加载到内存中。它的属性:

@Composable
fun LazyColumn(
    modifier: Modifier = Modifier,
    state: LazyListState = rememberLazyListState(),
    contentPadding: PaddingValues = PaddingValues(0.dp),
    reverseLayout: Boolean = false,
    verticalArrangement: Arrangement.Vertical =
        if (!reverseLayout) Arrangement.Top else Arrangement.Bottom,
    horizontalAlignment: Alignment.Horizontal = Alignment.Start,
    flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(),
    content: LazyListScope.() -> Unit
)

可以看出,LazyColumn 支持设置内容的 padding;支持设置成逆序,即子元素从下往上排列;支持设置滑动行为控制等等。使用起来很方便,往 content 里面添加需要展示的 item 即可。item 可以是 list,也可以是单个的 Composable 函数,需要用 items 或 item 包裹起来:

LazyColumn {
    // Add a single item
    item {
        Text(text = "First item")
    }

    // Add 5 items
    items(5) { index ->
        Text(text = "Item: $index")
    }

    // Add another single item
    item {
        Text(text = "Last item")
    }
}

有 LazyColumn 那就对应的就有 LazyRow,LazyRow 就是横向滑动的列表。

4.8 ConstraintLayout 约束布局

Compose 中也可以使用 ConstraintLayout,是使用 Row、Column、Box 布局的另一种解决方案。在实现更大的布局、有许多复杂对齐要求以及布局嵌套过深的场景下,ConstraintLayout 用起来更加顺手。同样使用之前需要加入相关的依赖库:

// build.gradle
implementation "androidx.constraintlayout:constraintlayout-compose:1.0.0-alpha07"

在 Compose 中使用 ConstraintLayout 有几点需要注意的:

  1. ConstraintLayout 中的子元素是通过 createRefs() 或 createRef() 方法初始化声明的,并且每个子元素都会关联一个ConstraintLayout 中的 Composable 组件;
  2. 子元素之间的约束关系是通过 Modifier.constrainAs() 的 Lambda 表达式来实现的;
  3. 约束关系可以使用 linkTo 或其他约束方法实现;
  4. parent 是一个默认存在的引用,代表 ConstraintLayout 父布局本身,也是用于子元素的约束关联。

下面是一个简单的例子:

@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 水平中间
        })
    }
}

Jetpack-Compose 初探——学习笔记1~3 总结_第3张图片

5. Compose 自定义 Layout

在 Compose 中,一个 Composable 方法被执行时,会被添加到 UI 树中,然后会被渲染展示在屏幕上。这个 Composable 方法可以看成是一个 View 系统中的布局,在 Compose 中称为 Layout。每个 Layout 都有一个 parent Layout 和 0 个或多个 children,这跟 View 体系很像。当然,这个 Layout 自身也含有在它的 parent Layout 中的位置信息,包括位置坐标(x, y)和它的尺寸大小 width和height。

Layout 中的 children Layout 子元素会被调用去测量它们自身的大小,同时需要满足规定的 Constraints 约束。这些 Constraints 约束限制了 width 和 height 的最大值和最小值。当 Layout 把自己的 children Layout 测量完成之后,它自己的尺寸才会确定下来,又是递归。。。一旦一个 Layout 元素完成自身的测量,它就可以将自己的 children 根据 Constraints 约束在自己的空间中进行摆放了。是不是跟 View 体系一样?先测量后摆放。

最重要的是 Compose UI 不允许多次测量。 单次测量(Single-pass measurement)当然会提升渲染效率,尤其是在 Compose 处理深度较大的 UI 树时。如果一个 Layout 元素需要测量两次它的所有子元素,子元素中的子元素就会被测量四次,以此类推,测量的次数会随着布局深度成指数级增长。所以在 View 体系中开发一定要减少布局的层数。不然在需要重复测量的情况下,渲染效率将会及其低下。所以 Compose 中才做了不允许多次测量的限制。

Compose 中自定义一个 Layout 有两种情况:

  1. 自定义 Layout 没有其他子元素,就只是它自己本身,类似于 View 体系中的 “自定义View”;
  2. 自定义 Layout 有子元素,需要考虑子元素的摆放位置,类似于 View 体系中的 “自定义ViewGroup”。

先来看下第一种情况。

5.1 Compose 自定义 “View”

这种情况下,其实需要自定义的 Layout 就是自定义一个 Modifier 属性。就是去自己实现 Modifier 中 Layout 方法,实现如何测量以及放置它自己本身即可。一个常见的自定义 Layout Modifier 的结构代码如下:

fun Modifier.customLayoutModifier(...) {    // 可以自定义一些属性
    Modifier.layout { measurable, constraints ->
        ...    // 在这里需要自己实现 测量 和 放置的方法
    }
}

关键就是 Modifier.layout 方法,它有两个 lambda 表达式:

  1. measurable:用于子元素的测量和位置放置的;
  2. constraints:用于约束子元素 width 和 height 的最大值和最小值。

假如需要实现一个 Text 组件,可以设置文案的 baseline 距离组件顶部的高度,如下图上面的情况。
Jetpack-Compose 初探——学习笔记1~3 总结_第4张图片
通常只能设置 Text 顶部到父布局的高度,这种需要控制文案 baseline 到 Text 顶部高度的组件只能通过自定义一个 Modifier 属性来实现。具体实现代码:

// Compose 自定义“View”
fun Modifier.firstBaselineToTop(    // firstBaselineToTop 就是自定义的 modifier 方法名
    firstBaselineToTop: Dp    // 自定义 modifier 方法中的参数,这里只有一个
) {
    // Modifier.layout 是自定义 “View” 的核心方法
    Modifier.layout { measurable, constraints ->
        // 首先调用 measurable 的 measure 进行测量
        val placeable = measurable.measure(constraints)
        // 检查这个 Composable 组件是否存在 FirstBaseline
        check(placeable[FirstBaseline] != AlignmentLine.Unspecified)
        // 存在的情况下,获取 FirstBaseline 离 Composable 组件顶部的距离
        val firstBaseline = placeable[FirstBaseline]
        // 计算 Y 轴方向上 Composable 组件的放置位置
        val placeableY = firstBaselineToTop.roundToPx() - firstBaseline
        // 计算得出此 Composable 组件真正的 height 值
        val height = placeable.height + placeableY
        // 对元素进行位置的摆放
        layout(placeable.width, height) {
            placeable.placeRelative(0, placeableY)
        }
    }
}

当调用 measurable 的 measure 方法后,就会返回一个 Placeable 对象。在这里,可以将 layout 中的 constraints 约束条件传递给 measure 方法,或者传入我们自定义的约束条件的 lambda。因为在这个场景下不需要再去对测量进行任何的限制,所以直接传入 layout 中给的 constraints 即可。总之,这一步就是为了得到这个 Placeable 对象,拿到这个之后就可以在后面调用 Placeable 对象的 placeRelative 方法对元素进行位置的摆放了。

check 方法类似于一个 assert 断言,如果里面的结果是 false 则会抛出一个 IllegalStateException 异常。这里是检查下被我们自定义的 Modifier 修饰的 Composable 组件是否存在 FirstBaseline 属性,Text 组件里是存在 baseline 的,如果不存在当然就不能用我们自定义的这个 firstBaselineToTop Modifier了。

存在的情况下,再去获取这个 Baseline 与 此组件顶部的距离,也就是下图 中 c 的长度。图中蓝色框代表的是普通的 Text 组件所占的空间位置;黑色框代表的是屏幕边缘;红色虚线代表的是 Text 中的 Baseline。a 表示的就是我们自定义的 Modifier.firstBaselineToTop 方法的 firstBaselintToTop 参数。我们的目标就是可以根据传入的 firstBaselintToTop 参数计算出 Text 组件在 Y 轴上的摆放位置,以及真正的 width 和 height 值大小。
Jetpack-Compose 初探——学习笔记1~3 总结_第5张图片
之前在 layout 方法中调用了 measurable 的 measure 方法测量的是普通 Text 组件的宽高,即上图中蓝色框的宽高,而自定义的 Layout 的宽高则是图中用橙色和绿色标注的宽高尺寸。width 直接由 Placeable 对象就可获得(placeable.width),而高度由示意图可以得出计算方法:height = placeable.height + d,即普通 Text 的高度再加上 d,d = a - c,即 d = firstBaselintToTop - baseline。所以,d 就是 placeableY 参数。原来就是为了算出自定义 Layout 的 width 和 height,然后通过 layout 方法进行设置。

使用方法跟普通的 Modifier 属性一样设置:

Text(
    text = "我是栗子1",
    modifier = Modifier.firstBaselineToTop(40.dp),
    fontSize = 20.sp
)

5.2 Compose 自定义 “ViewGroup”

其实,Compose 中的 Row、Column 组件等都是使用 Layout 方法实现的,它也是 Compose 用来自定义一个 “ViewGroup” 的核心方法。所以,Compose 自定义 “ViewGroup” 就是通过 Layout 组件手动地对它其中的子元素进行测量和摆放,一个自定义 “ViewGroup”的 Layout 代码结构通常如下代码所示:

@Composable
fun CustomLayout(
    modifier: Modifier = Modifier,
    // 此处可添加自定义的参数
    content: @Composable () -> Unit
) {
    Layout(
        modifier = modifier,
        content = content
    ){ measurable, constraints ->
        // 对 children 进行测量和放置
        ···
    }
}

假如需要实现如下图所示的一个自定义 Layout,可以左右滑动,可以设置显示的行数。
Jetpack-Compose 初探——学习笔记1~3 总结_第6张图片
还是那几个流程:1)测量所有子元素尺寸;2)计算自定义 Layout 的尺寸;3)摆放子元素。首先就是测量 Layout 中子元素的尺寸,获得每个 child 的 Placeable 对象,用于后面的位置摆放。

@Composable
fun StaggeredGrid(
    modifier: Modifier = Modifier,
    rows: Int = 3,    // 自定义的参数,控制展示的行数,默认为3行
    content: @Composable () -> Unit
) {
    Layout(
        modifier = modifier,
        content = content
    ) { measurables, constraints ->
        // 用于记录每一横行的宽度信息
        val rowWidths = IntArray(rows) { 0 }
        // 用于记录每一横行的高度信息
        val rowHeights = IntArray(rows) { 0 }
        // 测量每个 child 尺寸,获得每个 child 的 Placeable 对象
        val placeables = measurables.mapIndexed { index, measurable ->
            // 标准流程:测量每个 child 尺寸,获得 placeable
            val placeable = measurable.measure(constraints)
            // 根据序号给每个 child 分组,记录每一个横行的宽高信息
            val row = index % rows
            rowWidths[row] += placeable.width
            rowHeights[row] = max(rowHeights[row], placeable.height)
            placeable    // 这句别忘了,返回每个 child 的placeable
        }
        ...
    }
}

需要额外两个数组用于记录每一横行的宽高信息,也是为了后续位置的摆放。然后对子元素进行了分组,将它们分散到每个横行中。

接下来,就是计算自定义 Layout 自身的尺寸。通过上面的操作,已经得知每行 children 的最大高度,那么所有行高度相加就可以得到自定义 Layout 的高度了;而所有行中宽度最大值就是自定义 Layout 的宽度。代码如下:

// code 13
    Layout(
        modifier = modifier,
        content = content
    ) { measurables, constraints ->
        ...
        // 自定义 Layout 的宽度取所有行中宽度最大值
        val width = rowWidths.maxOrNull()
            ?.coerceIn(constraints.minWidth.rangeTo(constraints.maxWidth))
            ?: constraints.minWidth
        // 自定义 Layout 的高度当然为所有行高度之和
        val height = rowHeights.sumOf { it }
            .coerceIn(constraints.minHeight.rangeTo(constraints.maxHeight))
        // 计算出每一行的元素在 Y轴 上的摆放位置
        val rowY = IntArray(rows) { 0 }
        for (i in 1 until rows) {
            rowY[i] = rowY[i - 1] + rowHeights[i - 1]
        }

        // 设置 自定义 Layout 的宽高
        layout(width, height) {
            // 摆放每个 child
            ...
        }
    }

在求宽度 width 时,使用了 coerceIn 方法对 width 进行了限制,限制 width 在 constraints 约束的最小值和最大值之间,如果超出了则会被设置成最小值或最大值。height 也是如此。然后还是调用 layout 方法来设置自定义 Layout 的宽高。

最后,就是调用 placeable.placeRelative(x, y) 方法将 children 摆放到屏幕上即可。当然,需要借助变量存储 X 轴 上的位置信息的。具体代码如下:

// code 14
    Layout(
        modifier = modifier,
        content = content
    ) { measurables, constraints ->
        ...
        // 计算出每一行的元素在 Y轴 上的摆放位置
        val rowY = IntArray(rows) { 0 }
        for (i in 1 until rows) {
            rowY[i] = rowY[i - 1] + rowHeights[i - 1]
        }

        // 设置 自定义 Layout 的宽高
        layout(width, height) {
            // 摆放每个 child
            val rowX = IntArray(rows) { 0 }  // child 在 X 轴的位置
            placeables.forEachIndexed { index, placeable ->
                val row = index % rows
                placeable.placeRelative(
                    rowX[row],
                    rowY[row]
                )
                rowX[row] += placeable.width
            }
        }
    }

综上,完整的这个自定义 Layout 的代码如下:

// 横向瀑布流自定义 layout
@Composable
fun StaggeredGrid(
    modifier: Modifier = Modifier,
    rows: Int = 3,  // 自定义的参数,控制展示的行数,默认为3行
    content: @Composable () -> Unit
) {
    Layout(
        modifier = modifier,
        content = content
    ) { measurables, constraints ->
        // 用于记录每一横行的宽度信息
        val rowWidths = IntArray(rows) { 0 }
        // 用于记录每一横行的高度信息
        val rowHeights = IntArray(rows) { 0 }
        // 测量每个 child 尺寸,获得每个 child 的 Placeable 对象
        val placeables = measurables.mapIndexed { index, measurable ->
            // 标准流程:测量每个 child 尺寸,获得 placeable
            val placeable = measurable.measure(constraints)
            // 根据序号给每个 child 分组,记录每一个横行的宽高信息
            val row = index % rows
            rowWidths[row] += placeable.width
            rowHeights[row] = max(rowHeights[row], placeable.height)
            placeable    // 这句别忘了,返回每个 child 的placeable
        }

        // 自定义 Layout 的宽度取所有行中宽度最大值
        val width = rowWidths.maxOrNull()
            ?.coerceIn(constraints.minWidth.rangeTo(constraints.maxWidth))
            ?: constraints.minWidth
        // 自定义 Layout 的高度当然为所有行高度之和
        val height = rowHeights.sumOf { it }
            .coerceIn(constraints.minHeight.rangeTo(constraints.maxHeight))
        // 计算出每一行的元素在 Y轴 上的摆放位置
        val rowY = IntArray(rows) { 0 }
        for (i in 1 until rows) {
            rowY[i] = rowY[i - 1] + rowHeights[i - 1]
        }

        // 设置 自定义 Layout 的宽高
        layout(width, height) {
            // 摆放每个 child
            val rowX = IntArray(rows) { 0 }  // child 在 X 轴的位置
            placeables.forEachIndexed { index, placeable ->
                val row = index % rows
                placeable.placeRelative(
                    rowX[row],
                    rowY[row]
                )
                rowX[row] += placeable.width
            }
        }
    }
}

还有一点需要注意的是,自定义的 Layout StaggeredGrid 的宽度是会超出屏幕的,所以在实际使用中,还得添加一个 Modifier.horizonalScroll 用于水平方向上滑动,这样才用着舒服~ 实际使用的代码样例如下:

Row(modifier = Modifier.horizontalScroll(rememberScrollState())){
    StaggeredGrid() {
        // 添加子元素
    }
}

参考文献

  1. https://developer.android.google.cn/jetpack/compose/why-adopt
  2. Jetpack Compose 基础知识
  3. https://developer.android.google.cn/jetpack/compose/mental-model?continue=https%3A%2F%2Fdeveloper.android.google.cn%2Fcourses%2Fpathways%2Fcompose%23article-https%3A%2F%2Fdeveloper.android.com%2Fjetpack%2Fcompose%2Fmental-model
  4. 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
  5. https://developer.android.google.cn/reference/kotlin/androidx/compose/runtime/CompositionLocal
  6. https://compose.net.cn/design/theme/understanding_material_theme/
  7. https://compose.net.cn/elements/surface/
  8. 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)
  9. 乐翁龙. 《Jetpack Compose - ConstraintLayout》https://blog.csdn.net/u010976213/article/details/111184997
  10. 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#6
  11. 大海螺Utopia。《Android文字基线(Baseline)算法》. https://www.jianshu.com/u/79e66729b5ec
  12. Jetpack Compose 博物馆 - 自定义Layout. https://compose.net.cn/layout/custom_layout/
  13. 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#7

你可能感兴趣的:(Android,Compose,Jetpack,kotlin,android,jetpack)