Jetpack-Compose 学习笔记(三)—— Compose 的自定义“View”(下)

简介: Jetpack-Compose 学习笔记(三)—— Compose 的自定义“View”(下)

3. 自定义一个 “ViewGroup”


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

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

对于一个自定义 Layout 来说,,最少需要三个参数:

  • modifier:由外部传入的修饰符,用来修饰我们自定义的这个 Layout 组件的一些属性或 Constraints;
  • content:我们自定义的这个 Layout 组件中所包含的子元素 children;
  • measurePolicy:熟悉 Kotlin 语法的同学们会知道,code 6 中 Layout 后跟着的 lambda 表达式其实也是 Layout 的一个参数,从字面意思上也可知道,这个是为了对 children 进行测量和摆放操作的。默认场景下只实现 measure 方法即可,当我们想让我们自定义的 Layout 组件适配 Intrinsics (官方称之为 固有特性测量)时,就需要重写 minIntrinsicWidth、minIntrinsicHeight、maxIntrinsicWidth、maxIntrinsicHeight 方法。篇幅原因以后再说哈~

这里我们用 Layout 组件自定义一个基本的简单的 Column 组件,用于竖直方向上摆放子元素,我们取名为 MyOwnColumn。如之前所述的,我们第一件事就是测量 children,并且只能测量一次。与之前的自定义“View”不同的是,这里需要测量的不是它本身的尺寸,而是测量它其中包含的所有 children 的尺寸:

// code 7
@Composable
fun MyOwnColumn(
    modifier: Modifier = Modifier,
    // 此处可添加自定义的参数
    content: @Composable () -> Unit
) {
    Layout(
        modifier = modifier,
        content = content
    ){ measurables, constraints ->
        // 对 children 进行测量和放置
        val placeables = measurables.map { measurable ->
            // 测量每个 child 的尺寸
            measurable.measure(constraints)
        }
        ...
    }
}

可以看出,在 map 里每个 child 都调用 measure 方法进行了测量,并且与之前一样,我们无需再针对测量进行限制,所以直接传入 Layout 中的 constraints 即可。到这里,我们已经测量了所有的 children 子元素。

在设置这些 children 的位置之前,我们还需要根据测量的 children 尺寸来计算得出我们自定义的 MyOwnColumn 组件自身的宽高了。下面代码是尽最大可能地设置我们自定义的 MyOwnColumn 的 Layout 尺寸:

// code 8
@Composable
fun MyOwnColumn(
    modifier: Modifier = Modifier,
    // 此处可添加自定义的参数
    content: @Composable () -> Unit
) {
    Layout(
        modifier = modifier,
        content = content
    ){ measurables, constraints ->
        // 对 children 进行测量和放置
        val placeables = measurables.map { measurable ->
            // 测量每个 child 的尺寸
            measurable.measure(constraints)
        }
        layout(constraints.maxWidth, constraints.maxHeight) {
            // 摆放 children
            ...
        }
    }
}

最后就可以对 children 进行摆放了。与上述的自定义“View”相同,我们也是调用placeable.placeRelative(x,y)来放置位置。因为是自定义一个 Column,需要竖直方向上一个个进行摆放,所以每个 child 水平方向上 x 肯定从最左边开始,设置为 0 。而竖直方向上需要一个变量记录下一个 child 在竖直方向上的位置值。详细代码如下:

// code 9
@Composable
fun MyOwnColumn(
    modifier: Modifier = Modifier,
    // 此处可添加自定义的参数
    content: @Composable () -> Unit
) {
    Layout(
        modifier = modifier,
        content = content
    ){ measurables, constraints ->
        // 对 children 进行测量和放置
        val placeables = measurables.map { measurable ->
            // 测量每个 child 的尺寸
            measurable.measure(constraints)
        }
        var yPosition = 0  // 记录下一个元素竖直方向上的位置
        layout(constraints.maxWidth, constraints.maxHeight) {
            // 摆放 children
            placeables.forEach { placeable ->  
                placeable.placeRelative(x = 0, yPosition)
                yPosition += placeable.height
            }
        }
    }
}

注意一下我们自定义的这个 Column 的宽高设置的是尽最大可能撑满父布局:layout(constraints.maxWidth, constraints.maxHeight),所以跟官方的 Column 是有很大的不同的。这里只是为了说明 Compose 中自定义一个“ViewGroup”的方法流程。

MyOwnColumn 在使用上与 Column 一致,只是占用父布局空间的策略不一样。官方的 Column 布局默认情况下宽高是尽可能小的占用父布局,类似于 wrap_content;而 MyOwnColumn 是尽可能大的占用父布局,类似于 match_parent。下图图6 也可以清楚地看到效果。

// code 10
@Composable
fun MyOwnColumnDemo() {
    MyOwnColumn(Modifier.padding(20.dp)) {
        Text("我是栗子1")
        Text("我是栗子2")
        Text("我是栗子3")
    }
}

image.png

对比一下 Compose 中的自定义 Layout 的两种方式,一种是针对某个组件进行的功能扩展,类似于 View 体系中对某个已有的 View 或直接继承 View 进行的自定义,它其实是自定义一个 Modifier 方法;另一种是针对某个容器组件的自定义,类似于 View 体系中对某个已有的 ViewGroup 或直接继承 ViewGroup 进行自定义,它其实就是一个 Layout 组件,是布局的主要核心组件。接下来让我们看看更加复杂的自定义 Layout。


4. 自定义复杂的 Layout


OK,了解了 Compose 自定义 Layout 的基本方法步骤,让我们看看一个稍微复杂的栗子。假如需要实现一个横向滑动的瀑布流布局,例如下图中间部分所示:


可以设置展示成多少行,这里是展示成 3 行,我们只需要传入所有的子元素即可。现有的官方 Compose 组件中没有这种功能的组件,这就需要定制化了。先按照之前的模板代码构建一下框架:

// code 11
@Composable
fun StaggeredGrid(
    modifier: Modifier = Modifier,
    rows: Int = 3,  // 自定义的参数,控制展示的行数,默认为 3行
    content: @Composable () -> Unit
){
    Layout(    // 主要还是这个 Layout 方法
        modifier = modifier,
        content = content
    ) { measurables, constraints ->
        // 测量和位置摆放逻辑
    }
}

接下来还是那个流程:1)测量所有子元素尺寸;2)计算自定义 Layout 的尺寸;3)摆放子元素。这里只展示 Layout 方法中的代码:

// code 12
    Layout(
        modifier = modifier,
        content = content
    ) { measurables, constraints ->
        // 用于记录每一行的宽度信息
        val rowWidths = IntArray(rows){0}
        // 用于记录每一行的高度信息
        val rowHeights = IntArray(rows){0}
        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 // 测量完了要记得返回 placeable 对象
        }
        ...
    }

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

// 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 的代码如下:

// code 15
// 横向瀑布流自定义 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
            }
        }
    }
}

OK,再写一个小的组件作为 children 子元素,用来显示,具体代码如下:

// code 16
@Composable
fun Chip(
    modifier: Modifier = Modifier, text: String
) {
    Card(
        modifier = modifier,
        border = BorderStroke(color = Color.Magenta, width = Dp.Hairline),
        shape = RoundedCornerShape(8.dp)
    ) {
        Row(
            modifier = Modifier.padding(start = 8.dp, top = 4.dp, end = 8.dp, bottom = 4.dp),
            verticalAlignment = Alignment.CenterVertically
        ) {
            Box(
                modifier = Modifier
                    .size(16.dp, 16.dp)
                    .background(color = MaterialTheme.colors.secondary)
            )
            Spacer(Modifier.width(4.dp))
            Text(text = text)
        }
    }
}

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

// code 17
val topics = listOf(
    "Arts & Crafts", "Beauty", "Books", "Business", "Comics", "Culinary",
    "Design", "Fashion", "Film", "History", "Maths", "Music", "People", "Philosophy",
    "Religion", "Social sciences", "Technology", "TV", "Writing"
)
Row(modifier = Modifier.horizontalScroll(rememberScrollState())){
    StaggeredGrid() {
        for (topic in topics) {
            Chip(modifier = Modifier.padding(8.dp),text = topic)
        }
    }
}

image.png

当然,还支持自己设置需要展示成几行的样式,这里默认值为 3行。

总结一下,在 Compose 中自定义 Layout 的基本流程其实跟 View 体系中自定义 View 的一样,其中最大的不同就是在测量的步骤,Compose 为提高效率不允许多次进行测量。而且 Compose 的自定义 Layout 的两种情况也可以对应到 View 体系中的两个情况,但可以看出,Compose 都是在 Layout 组件中进行的改写与编程,可以让开发者更加聚焦在具体的代码逻辑上,这也是 Compose 自定义 Layout 的优势所在。那么,Compose 的自定义“View”,你学会了么?

ps. 赠人玫瑰,手留余香。欢迎转发分享加关注,你的认可是我继续创作的精神源泉。


参考文献


  1. developer.android.google.cn/codelabs/je…
  2. 大海螺Utopia。《Android文字基线(Baseline)算法》. www.jianshu.com/u/79e66729b…
  3. Jetpack Compose 博物馆 - 自定义Layout. compose.net.cn/layout/cust…
  4. developer.android.google.cn/codelabs/je…
目录
相关文章
|
2月前
|
安全 Java Android开发
探索安卓应用开发的新趋势:Kotlin和Jetpack Compose
在安卓应用开发领域,随着技术的不断进步,新的编程语言和框架层出不穷。Kotlin作为一种现代的编程语言,因其简洁性和高效性正逐渐取代Java成为安卓开发的首选语言。同时,Jetpack Compose作为一个新的UI工具包,提供了一种声明式的UI设计方法,使得界面编写更加直观和灵活。本文将深入探讨Kotlin和Jetpack Compose的特点、优势以及如何结合使用它们来构建现代化的安卓应用。
65 4
|
4月前
|
存储 移动开发 Android开发
使用kotlin Jetpack Compose框架开发安卓app, webview中h5如何访问手机存储上传文件
在Kotlin和Jetpack Compose中,集成WebView以支持HTML5页面访问手机存储及上传音频文件涉及关键步骤:1) 添加`READ_EXTERNAL_STORAGE`和`WRITE_EXTERNAL_STORAGE`权限,考虑Android 11的分区存储;2) 配置WebView允许JavaScript和文件访问,启用`javaScriptEnabled`、`allowFileAccess`等设置;3) HTML5页面使用`<input type="file">`让用户选择文件,利用File API;
|
5月前
|
JavaScript Java Android开发
kotlin安卓在Jetpack Compose 框架下跨组件通讯EventBus
**EventBus** 是一个Android事件总线库,简化组件间通信。要使用它,首先在Gradle中添加依赖`implementation &#39;org.greenrobot:eventbus:3.3.1&#39;`。然后,可选地定义事件类如`MessageEvent`。在活动或Fragment的`onCreate`中注册订阅者,在`onDestroy`中反注册。通过`@Subscribe`注解方法处理事件,如`onMessageEvent`。发送事件使用`EventBus.getDefault().post()`。
|
5月前
|
安全 JavaScript 前端开发
kotlin开发安卓app,JetPack Compose框架,给webview新增一个按钮,点击刷新网页
在Kotlin中开发Android应用,使用Jetpack Compose框架时,可以通过添加一个按钮到TopAppBar来实现WebView页面的刷新功能。按钮位于右上角,点击后调用`webViewState?.reload()`来刷新网页内容。以下是代码摘要:
|
5月前
|
JavaScript 前端开发 Android开发
kotlin安卓在Jetpack Compose 框架下使用webview , 网页中的JavaScript代码如何与native交互
在Jetpack Compose中使用Kotlin创建Webview组件,设置JavaScript交互:`@Composable`函数`ComposableWebView`加载网页并启用JavaScript。通过`addJavascriptInterface`添加`WebAppInterface`类,允许JavaScript调用Android方法如播放音频。当页面加载完成时,执行`onWebViewReady`回调。
|
5月前
深入了解 Jetpack Compose 中的 Modifier
深入了解 Jetpack Compose 中的 Modifier
102 0
|
5月前
|
Android开发
Jetpack Compose: Hello Android
Jetpack Compose: Hello Android
|
6月前
|
存储 安全 Android开发
构建高效的Android应用:Kotlin与Jetpack的结合
【5月更文挑战第31天】 在移动开发的世界中,Android 平台因其开放性和广泛的用户基础而备受开发者青睐。随着技术的进步和用户需求的不断升级,开发一个高效、流畅且易于维护的 Android 应用变得愈发重要。本文将探讨如何通过结合现代编程语言 Kotlin 和 Android Jetpack 组件来提升 Android 应用的性能和可维护性。我们将深入分析 Kotlin 语言的优势,探索 Jetpack 组件的核心功能,并通过实例演示如何在实际项目中应用这些技术。
|
5月前
|
数据管理 API 数据库
探索Android Jetpack:现代安卓开发的利器
Android Jetpack是谷歌为简化和优化安卓应用开发而推出的一套高级组件库。本文深入探讨了Jetpack的主要构成及其在应用开发中的实际运用,展示了如何通过使用这些工具来提升开发效率和应用性能。
|
4月前
|
存储 数据库 Android开发
🔥Android Jetpack全解析!拥抱Google官方库,让你的开发之旅更加顺畅无阻!🚀
【7月更文挑战第28天】在Android开发中追求高效稳定的路径?Android Jetpack作为Google官方库集合,是你的理想选择。它包含多个独立又协同工作的库,覆盖UI到安全性等多个领域,旨在减少样板代码,提高开发效率与应用质量。Jetpack核心组件如LiveData、ViewModel、Room等简化了数据绑定、状态保存及数据库操作。引入Jetpack只需在`build.gradle`中添加依赖。例如,使用Room进行数据库操作变得异常简单,从定义实体到实现CRUD操作,一切尽在掌握之中。拥抱Jetpack,提升开发效率,构建高质量应用!
70 4
下一篇
无影云桌面