Jetpack Compose 学习笔记(二)—— 布局

关于布局的官方资料,可以参考Compose 中的布局

Compose 中布局的目标:

  • 实现高性能
  • 让开发者能轻松编写自定义布局
  • Compose 通过避免多次测量布局子集以实现高性能。如果需要多次测量,Compose 具有一个特殊系统,即固有特性测量

1、标准布局组件

标准布局组件包括:

  • Box:相当于 FrameLayout,将一个元素放在另一个元素之上
  • Column:相当于纵向的 LinearLayout,垂直摆放元素
  • Row:相当于横向的 LinearLayout,水平摆放元素
  • BoxWithConstraints:新增

修饰符的作用类似于基于视图的布局中的布局参数。借助修饰符,您可以修饰或扩充可组合项。可以使用修饰符来执行以下操作:

  • 更改可组合项的大小、布局、行为和外观
  • 添加信息,如无障碍标签
  • 处理用户输入
  • 添加高级互动,如使元素可点击、可滚动、可拖动或可缩放

利用标准布局组件做一个信息卡,示例代码:

@Composable
fun PhotographerCard(modifier: Modifier = Modifier) {
    Row(
        modifier = modifier
            // 圆角,4dp
            .clip(RoundedCornerShape(4.dp))
            .background(MaterialTheme.colors.surface)
            // 点击事件,虽然是空的,但是在点击时会出现背景的水波纹效果
            .clickable { }
            // 内边距,Compose 取消了外边距
            .padding(16.dp)
    ) {
        Surface(
            modifier = Modifier.size(50.dp),
            shape = CircleShape,
            // onSurface 是在 surface 上显示的前景元素颜色,这里通过 copy 修改了 alpha 值
            color = MaterialTheme.colors.onSurface.copy(alpha = 0.2f)
        ) {
            Image(
                painter = painterResource(id = R.drawable.img),
                contentDescription = null
            )
        }

        Column(
            modifier = Modifier
                .padding(start = 8.dp)
                .align(alignment = Alignment.CenterVertically)
        ) {
            Text(text = "Alfred Sisley", fontWeight = FontWeight.Bold)
            // CompositionLocal 隐式提供颜色的 Alpha 值给 lambda 内的所有执行内容
            CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
                Text(text = "3 minutes ago", style = MaterialTheme.typography.body2)
            }
        }
    }
}

效果图如下:

2024-9-19.标准布局示例预览

要说明的几点:

  1. Row 的 modifier 目前是先 clickable 后 padding,所以背景的水波纹效果是遍布整个条目的。如果反过来先 padding 再 clickable,那么水波纹效果就只会在刨除 padding 的部分显示
  2. CompositionLocal 会进行隐式的参数传递,比如上面的 CompositionLocalProvider 会将参数内得到的 ProvidedValue 隐式传给 lambda 表达式作为参数。provides 是一个中缀函数,生成一个 ProvidedValue 对象,包含 this,也就是主调的 CompositionLocal,这里就是 LocalContentAlpha 以及参数内的值,这里就是 ContentAlpha.medium。lambda 内所有用到透明度的地方都会接收这个隐式参数

假如对第二个 Text 不使用 CompositionLocal 调整它的透明度,就是下面的效果:

2024-9-19.标准布局示例预览1

2、Slots API

Material 组件大量使用槽位 API,这是 Compose 引入的一种模式(很像 Vue 的插槽),它在可组合项之上带来一层自定义设置。这种方法使组件变得更加灵活,因为它们接受可以自行配置的子元素,而不必公开子元素的每个配置参数。槽位会在界面中留出空白区域,让开发者按照自己的意愿来填充。例如,下面是您可以在 TopAppBar 中自定义的槽位:

2024-09-19.layout-appbar-slots

Scaffold 可让您实现具有基本 Material Design 布局结构的界面。Scaffold 可以为最常见的顶级 Material 组件(如 TopAppBarBottomAppBarFloatingActionButtonDrawer)提供槽位。通过使用 Scaffold,可轻松确保这些组件得到适当放置且正确地协同工作。

比如,为了实现如下的效果:

2024-9-19.插槽API效果

可以使用 Scaffold 指定 TopAppBar 的 title 和 actions 参数:

@Composable
fun LayoutStudy() {
    Scaffold(
        topBar = {
            TopAppBar(
                title = {
                    Text(text = "LayoutStudy")
                },
                actions = {
                    IconButton(onClick = {}) {
                        Icon(imageVector = Icons.Filled.Favorite, contentDescription = null)
                    }
                }
            )
        }
    ) { innerPadding ->
        // PaddingValues(start=0.0.dp, top=0.0.dp, end=0.0.dp, bottom=0.0.dp)
        BodyContent(Modifier.padding(innerPadding))
    }
}

@Composable
fun BodyContent(modifier: Modifier = Modifier) {
    // Body 的内容,指定两行文字
    Column(modifier.padding(8.dp)) {
        Text(text = "Hi there!")
        Text(text = "Thanks for going through the LayoutStudy")
    }
}

Scaffold 构造函数的参数上将 topBar、bottomBar、floatingActionButton 和 drawerContent 这几个属性设置为插槽 API,也就是 Composable 函数 @Composable () -> Unit:

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

最后的 content 属性也是一个 Composable 函数,会传入参数 PaddingValues 以构造 Body 的内容。在编码时,打印这个传入的 PaddingValues,值均为 0:PaddingValues(start=0.0.dp, top=0.0.dp, end=0.0.dp, bottom=0.0.dp),所以 Body 不分用 Column 展示的两行文字,它与左上角的间距,是 Column 参数上的 padding 造成的,而不是 Column 所在的 BodyContent 的 Modifier 带来的间距。

总之,插槽 API 就是通过 @Composable () -> Unit 实现的插槽。

3、使用列表

如果知道用例不需要任何滚动,可以使用简单的 Column 或 Row,如果需要显示大量列表项,或者列表长度未知,则使用 LazyColumn 或 LazyRow。

无法滚动的列表:

@Composable
fun SimpleColumn() {
    Column {
        repeat(100) {
            Text(text = "Item $it", style = MaterialTheme.typography.subtitle1)
        }
    }
}

可以滚动的列表,需要给 Column 通过 Modifier 的扩展函数指定一个 scrollState:

@Composable
fun SimpleList() {
    val scrollState = rememberScrollState()
    Column(Modifier.verticalScroll(scrollState)) {
        repeat(100) {
            Text(text = "Item $it", style = MaterialTheme.typography.subtitle1)
        }
    }
}

手指触碰页面使得页面状态发生改变,这个状态由 scrollState 负责记录。将其传给 Column 后,由 Compose 的底层负责两种状态之间的转换,使用者无需关注底层如何转换状态。只需记住,任何 UI 的交互都是通过 State 完成,Compose 对 UI 进行重组(理解为重绘)都是通过 State 变化触发的。

大量列表项需使用 LazyColumn:

@Composable
fun LazyList() {
    LazyColumn {
        items(100) {
            Text(text = "Item $it", style = MaterialTheme.typography.subtitle1)
        }
    }
}

由于 LazyColumn 的构造函数中给 state 参数设置了默认值 rememberLazyListState(),因此不用像视频教程中那样自己用 rememberLazyListState() 创建一个然后显式传给 LazyColumn:

@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(),
    userScrollEnabled: Boolean = true,
    content: LazyListScope.() -> Unit
)

下面要做一个例子,通过点击按钮控制列表的滑动。原理是,UI 界面更新,包括列表的滑动,都是根据 State 对象的变化而做出相应的改变。用户用手指滑动列表造成的滑动,也是因为改变了 State 然后 Compose 底层根据状态进行转移的。因此我们做这个例子就直接通过按钮改变 State 控制列表滑动:

@Composable
fun ScrollingList() {
    val listSize = 100
    val coroutineScope = rememberCoroutineScope()
    val lazyListState = rememberLazyListState()

    Column {
        Row {
            Button(
                modifier = Modifier.weight(1f),
                onClick = {
                    // animateScrollToItem 是挂起函数
                    coroutineScope.launch {
                        lazyListState.animateScrollToItem(0)
                    }
                }
            ) {
                Text(text = "Scroll to the top")
            }
            Button(
                modifier = Modifier.weight(1f),
                onClick = {
                    coroutineScope.launch {
                        lazyListState.animateScrollToItem(listSize - 1)
                    }
                }
            ) {
                Text(text = "Scroll to the end")
            }
        }

        LazyColumn(state = lazyListState) {
            items(listSize) {
                ImageListItem(it)
            }
        }
    }
}

@OptIn(ExperimentalCoilApi::class)
@Composable
fun ImageListItem(index: Int) {
    Row(verticalAlignment = Alignment.CenterVertically) {
        Image(
            // 通过 Coil 提供的函数直接获取图片
            painter = rememberImagePainter(
                // 用网络图片加载不出来,已经给了网络权限了,不知道啥情况,先不纠结这个,用本地代替
//                data = "https://developer.android.google.cn/images/logos/android.svg"
                data = R.mipmap.ic_launcher
            ),
            contentDescription = null,
            modifier = Modifier.size(50.dp)
        )
        Spacer(modifier = Modifier.size(10.dp))
        Text(text = "Item $index", style = MaterialTheme.typography.subtitle1)
    }
}

效果如下:

2024-09-19.通过按钮滚动列表

代码不用做太详细的解释,参考注释即可。但是有些头疼的是,这样一个小示例仍有两处 bug 未能解决:

  1. 无法手动滑动列表,不知道是不是 Column 嵌套 LazyColumn 的问题,暂时不纠结(教程中可以滑)
  2. 无法通过 Coil 直接加载显示网络图片,网络权限已经给了,暂时不纠结(教程中可以显示网络图片)

4、自定义布局

本节的理论知识主要来自官方文档:自定义版式

在 Compose 中,界面元素由可组合函数表示,此类函数在被调用后会发出一部分界面,这部分界面随后会被添加到呈现在屏幕上的界面树中。每个界面元素都有一个父元素,还可能有多个子元素。此外,每个元素在其父元素中都有一个位置,指定为 (x, y) 位置;也都有一个尺寸,指定为 widthheight

在界面树中布置每个节点的过程分为三个步骤。每个节点必须:

  1. 测量所有子项
  2. 确定自己的尺寸
  3. 放置其子项

注意:Compose 界面不允许多遍测量。这意味着,布局元素不能为了尝试不同的测量配置而多次测量任何子元素。

作用域的使用决定了你可以衡量和放置子项的时机。只能在测量和布局传递期间测量布局,并且只能在布局传递期间(且仅在已进行测量之后)才能放置子项。由于 Compose 作用域,例如 MeasureScope、 和PlacementScope 这是在编译时强制执行的。

4.1 使用布局修饰符

您可以使用 layout 修饰符来修改元素的测量和布局方式。Layout 是一个 lambda;它的参数包括您可以测量的元素(以 measurable 的形式传递)以及该可组合项的传入约束条件(以 constraints 的形式传递)。

假如,现在想控制 Text 的第一行文本基线到顶部的距离,如下面图一所示,First Baseline 到顶部的距离为 24.dp;下面图二则是正常情况下,设置 Text 的上内边距为 24.dp 的情况:

2024-09-19.layout-padding-baseline

来看如何通过修饰符来做到第一种情况:

fun Modifier.firstBaselineToTop(firstBaselineToTop: Dp) =
	// then 会将参数内的 Modifier 与 this 进行合并生成一个 CombinedModifier
    this.then(
        // layout 闭包内在调用 layout() 之前会一直报错,提醒你调用 layout()
        layout { measurable, constraints ->
            // 测量元素
            val placeable = measurable.measure(constraints)
            // 测量之后,获取元素的基线值(先检查它有基线)
            check(placeable[FirstBaseline] != AlignmentLine.Unspecified)
            val firstBaseline = placeable[FirstBaseline]
            // 元素的 Y 坐标,即左上角的 Y 坐标 = 基线到顶的距离 firstBaselineToTop - 基线值,
            // 其中基线值是基线到 Text 顶部的距离,这样一减能求出 Y 坐标
            val placeableY = firstBaselineToTop.roundToPx() - firstBaseline
            val height = placeable.height + placeableY
            layout(placeable.width, height) {
                // 设置元素位置
                placeable.placeRelative(0, placeableY)
            }
        }
    )

@Composable
fun TextWithPaddingToBaseline() {
    JetpackComposeTheme {
        Text(
            "Hi there!",
            modifier = Modifier.firstBaselineToTop(24.dp)
        )
    }
}

代码解释:

  1. this.then() 是为了让整个 firstBaselineToTop 可以返回一个 Modifier,这样能让调用端进行链式调用。then() 会检查传入的参数是否与 this 是同一个对象,如果是则返回该对象,否则将 this 与参数的 Modifier 合并为 CombinedModifier 再返回

  2. then() 的参数是一个 Modifier,由 Modifier 的扩展函数 layout 提供:

    // layout 使用了 this.then,因此在 firstBaselineToTop 中其实可以不用 this.then 的,
    // 官方给的示例代码就没用,但是课程里用了,不知道是否有什么深意
    fun Modifier.layout(
        measure: MeasureScope.(Measurable, Constraints) -> MeasureResult
    ) = this.then(
        LayoutModifierImpl(
            measureBlock = measure,
            inspectorInfo = debugInspectorInfo {
                name = "layout"
                properties["measure"] = measure
            }
        )
    )
    

    layout() 的参数要传入一个负责进行测量,并返回测量结果 MeasureResult 的函数,实际上就是上面 layout 的闭包内容

  3. layout 内传入的参数 measurable, constraints 就是 layout 定义上 measure 的两个参数,measurable 表示的就是 Text,先通过 measurable.measure(constraints) 对其进行测量

  4. 计算好后宽高和摆放位置后,通过 MeasureScope 的 layout() 设置控件的尺寸,并在最后的 lambda 中通过 placeable.place(x, y) 放置控件,如不进行摆放则该元素不可见

元素的高度计算暂时搞不明白,先不纠结,继续往下学。

4.2 仿 Column 组件

模仿 Column 组件实现一个垂直摆放控件的组件,效果如下:

2024-09-20.layout-complex-by-hand

实现一个自定义 Layout:

@Composable
fun MyBasicColumn(
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {
    Layout(
        modifier = modifier,
        content = content
    ) { measurables: List<Measurable>, constraints: Constraints ->
        // 直接使用给定的约束测量所有子 View
        val placeables = measurables.map { measurable ->
            // 测量每一个孩子
            measurable.measure(constraints)
        }

        // 设置每一个子 View 的尺寸,给它约束范围内最大的尺寸
        // 由于这个闭包实现的 measure 函数要求返回 MeasureResult,因此在调用 layout 之前会一直报错
        layout(constraints.maxWidth, constraints.maxHeight) {
            var yPosition = 0

            // 在父布局中摆放孩子
            placeables.forEach { placeable ->
                placeable.placeRelative(x = 0, y = yPosition)
                yPosition += placeable.height
            }
        }
    }
}

@Composable
fun CallingComposable(modifier: Modifier = Modifier) {
    MyBasicColumn(modifier.padding(8.dp)) {
        Text("MyBasicColumn")
        Text("places items")
        Text("vertically.")
        Text("We've done it by hand!")
    }
}

这里使用的进行布局的 Layout 是定义在 Layout.kt 中的 Composable 函数:

@Suppress("ComposableLambdaParameterPosition")
@UiComposable
@Composable inline fun Layout(
    content: @Composable @UiComposable () -> Unit,
    modifier: Modifier = Modifier,
    measurePolicy: MeasurePolicy
) {
    val density = LocalDensity.current
    val layoutDirection = LocalLayoutDirection.current
    val viewConfiguration = LocalViewConfiguration.current
    ReusableComposeNode<ComposeUiNode, Applier<Any>>(
        factory = ComposeUiNode.Constructor,
        update = {
            set(measurePolicy, ComposeUiNode.SetMeasurePolicy)
            set(density, ComposeUiNode.SetDensity)
            set(layoutDirection, ComposeUiNode.SetLayoutDirection)
            set(viewConfiguration, ComposeUiNode.SetViewConfiguration)
        },
        skippableUpdate = materializerOf(modifier),
        content = content
    )
}

最后的参数 MeasurePolicy 是一个单抽象方法接口(Single Abstract Method interface),缩写为 SAM 接口,也称为函数式接口:

fun interface MeasurePolicy {
    
    fun MeasureScope.measure(
        measurables: List<Measurable>,
        constraints: Constraints
    ): MeasureResult
    ...
}

通过参数可以看出,实际上我们给 Layout 传的第三个参数 MeasurePolicy 是直接通过 SAM 转换,实现其 measure 函数。由于该函数要求返回 MeasureResult,因此在调用 layout 之前会一直报错。

注意:在 View 系统中,创建自定义布局必须扩展 ViewGroup 并实现测量和布局函数。在 Compose 中,您只需使用 Layout 可组合项编写一个函数即可。

4.3 StaggeredGrid

本节我们是自己实现一个可以横向滑动的流式布局,但是官方实际上提供了新的试验功能 FlowRow 和 FlowColumn,可参考官方资料Compose 中的流式布局

再来看一个复杂一点的自定义布局 —— 可以横向滑动的瀑布流布局:

2024-09-20.横向滑动的瀑布流布局

先说排列规则,规定纵向最多放 3 行数据,摆放每一个标签时,是纵向排列的:

2024-9-20.StaggeredGrid摆放规则

即如上图所示,先在第一列摆放 0、1、2,摆满之后再到第二列摆放 3、4、5,以此类推。

首先来实现标签:

@Composable
fun Chip(
    modifier: Modifier = Modifier,
    text: String
) {
    Card(
        modifier = modifier,
        // 边框,黑色,宽度为 1 个像素
        border = BorderStroke(color = Color.Black, width = Dp.Hairline),
        // 圆角
        shape = RoundedCornerShape(8.dp)
    ) {
        Row(
            modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
            verticalAlignment = Alignment.CenterVertically
        ) {
            Box(
                modifier = Modifier
                    .size(16.dp)
                    .background(MaterialTheme.colors.secondary)
            )

            Spacer(modifier = Modifier.width(4.dp))

            Text(text)
        }
    }
}

预览图:

2024-9-20.Chip预览效果

然后对这个自定义布局进行测量和布局:

@Composable
fun StaggeredGrid(
    modifier: Modifier = Modifier,
    rows: Int = 3, // 默认有 3 行
    content: @Composable () -> Unit
) {
    Layout(
        modifier = modifier,
        content = content
    ) { mesurables, constraints ->
        // 记录每一行的宽度和高度
        val rowWidths = IntArray(rows) { 0 }
        val rowHeights = IntArray(rows) { 0 }

        val placeables = mesurables.mapIndexed { index, measurable ->
            val placeable = measurable.measure(constraints)
            // 行号
            val row = index % rows
            // 更新本行的宽高
            rowWidths[row] += placeable.width
            rowHeights[row] = max(rowHeights[row], placeable.height)
            placeable
        }

        // 得到组件测量后的尺寸,宽度应该是最大行宽,高度是每一行的高度之和
        val width = rowWidths.maxOrNull() ?: constraints.minWidth
        val height = rowHeights.sumOf { it }

        // 计算每一行纵坐标的值
        val rowY = IntArray(rows) { 0 }
        for (i in 1 until rows) {
            rowY[i] = rowY[i - 1] + rowHeights[i - 1]
        }

        // layout 内要计算出每个 Chip 的坐标并摆放它们
        layout(width = width, height = height) {
            val rowX = IntArray(rows) { 0 }
            placeables.forEachIndexed { index, placeable ->
                val row = index % rows
                placeable.placeRelative(
                    x = rowX[row],
                    y = rowY[row]
                )
                // 将当前行的 x 坐标增加当前 Chip 的宽度,这样就计算出本行下一个 Chip 的 x 坐标了
                rowX[row] += placeable.width
            }
        }
    }
}

最后向 StaggeredGrid 这个布局填充数据:

@Composable
fun StaggeredGridBodyContent(modifier: Modifier = Modifier) {
    Row(
        modifier = modifier
            .background(color = Color.LightGray)
            .padding(16.dp)
            .horizontalScroll(rememberScrollState()),
    ) {
        StaggeredGrid {
            topics.forEach {
                Chip(modifier = Modifier.padding(8.dp), text = it)
            }
        }
    }
}

放入 setContent 中进行显示:

	override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            JetpackComposeTheme {
                // A surface container using the 'background' color from the theme
                Surface {
                    StaggeredGridBodyContent()
                }
            }
        }
    }

需要注意一下,AS 创建的模板代码原本给 Surface 设置了 modifier = Modifier.fillMaxSize(),导致 Surface 强迫 StaggeredGridBodyContent 在高度上占满整个屏幕,由于我们不需要这样,因此去掉了该修饰符,最终效果如前面的动图演示那样。

5、约束布局

官方介绍:Compose 中的 ConstraintLayout

在实现对齐要求比较复杂的较大布局时,ConstraintLayout 很有用。

Compose 中的 ConstraintLayout 使用 DSL 按以下方式运作:

  • 使用 createRefs()createRefFor()ConstraintLayout 中的每个可组合项创建引用。
  • 约束条件是使用 constrainAs() 修饰符提供的,该修饰符将引用作为参数,可让您在主体 lambda 中指定其约束条件。
  • 约束条件是使用 linkTo() 或其他有用的方法指定的。
  • parent 是一个现有的引用,可用于指定对 ConstraintLayout 可组合项本身的约束条件。

光看这四条,感觉有点不太直观,我们通过示例来理解它们。

5.1 基本使用

在 Compose 中使用 ConstraintLayout 需要先添加依赖:

	// Compose 对 ConstraintLayout 的支持
    implementation 'androidx.constraintlayout:constraintlayout-compose:1.0.0'

先来做第一个效果,预览图如下:

2024-9-20.约束布局预览1

示例代码:

@Composable
fun ConstraintLayoutContent() {
    ConstraintLayout {
        // 1.使用 createRefs() 或 createRefFor() 为 ConstraintLayout 中的每个可组合项创建引用
        val (button, text) = createRefs()
        Button(
            onClick = {},
            // 2 使用 constrainAs() 修饰符提供约束条件,可组合项的引用作为参数,lambda 指定约束条件
            modifier = Modifier.constrainAs(button) {
                // 3.使用 linkTo() 或其他有用的方法指定约束条件
                // 4.parent 是一个现有的引用,可用于指定对 ConstraintLayout 可组合项本身的约束条件
                top.linkTo(parent.top, margin = 16.dp)
            }
        ) {
            Text("Button")
        }
        // Text 的约束与 Button 类似
        Text(
            text = "Text",
            modifier = Modifier.constrainAs(text) {
                top.linkTo(button.bottom, margin = 16.dp)
                // 在约束布局中水平居中
                centerHorizontallyTo(parent)
            }
        )
    }
}

createRefs() 实际上是创建了一个定义了解构操作符的类 ConstrainedLayoutReferences 的对象:

	@Stable
    fun createRefs() =
            referencesObject ?: ConstrainedLayoutReferences().also { referencesObject = it }
	// referencesObject 唯一赋值的地方就是通过 createRefs()
    private var referencesObject: ConstrainedLayoutReferences? = null

ConstrainedLayoutReferences 其实就是为每一个解构函数通过 createRef() 创建了一个 ConstrainedLayoutReference:

	inner class ConstrainedLayoutReferences internal constructor() {
        operator fun component1() = createRef()
        operator fun component2() = createRef()
        operator fun component3() = createRef()
        operator fun component4() = createRef()
        operator fun component5() = createRef()
        operator fun component6() = createRef()
        operator fun component7() = createRef()
        operator fun component8() = createRef()
        operator fun component9() = createRef()
        operator fun component10() = createRef()
        operator fun component11() = createRef()
        operator fun component12() = createRef()
        operator fun component13() = createRef()
        operator fun component14() = createRef()
        operator fun component15() = createRef()
        operator fun component16() = createRef()
    }

因此上面示例中的 button 和 text 实际上都是 ConstrainedLayoutReference 类型的对象。

再来看第二个示例:

2024-9-20.约束布局预览2

Button1 的顶部距离约束布局的顶部有 16dp,Text 的底部距离 Button1 的底部有 16dp,同时,Text 的中间位置与 Button1 的 End 对齐,然后 Button1 与 Text 组成一个屏障,让 Button2 的 Start 与该屏障连接。

示例代码:

@Composable
fun ConstraintLayoutContent1() {
    ConstraintLayout(modifier = Modifier.background(Color.LightGray)) {
        val (button1, button2, text) = createRefs()
        // Button1 没啥特殊的,距离 parent 的顶部 16dp
        Button(
            onClick = { /*TODO*/ },
            modifier = Modifier.constrainAs(button1) {
                top.linkTo(parent.top, margin = 16.dp)
            }
        ) {
            Text(text = "Button1")
        }

        Text(
            text = "Text",
            modifier = Modifier.constrainAs(text) {
                // 1.将 Text 放置在 Button1 的底部,距离底部 16dp
                top.linkTo(button1.bottom, margin = 16.dp)
                // 2.指定 Text 的中间位置和 Button1 的结束位置对齐
                centerAround(button1.end)
            }
        )

        // 将 button1 与 text 组成一个屏障
        val barrier = createEndBarrier(button1, text)

        // Button2 要的 start 要连接到 Button1 与 Text 组成的屏障之后
        Button(
            onClick = { /*TODO*/ },
            modifier = Modifier.constrainAs(button2) {
                top.linkTo(parent.top, margin = 16.dp)
                start.linkTo(barrier)
            }
        ) {
            Text(text = "Button2")
        }
    }
}

最后来看一个属性 Dimension.preferredWrapContent,可以在文字超出屏幕范围时使用。具体的例子,先创建一个垂直反向的 Guideline,占比 50%,即在水平中央的位置。然后让一个包含很长很长内容的 Text 在 Guideline 之后,你会发现即便设置了 Text 的 End 对齐 Parent 的 End,但文字仍会超出屏幕:

@Composable
fun LargeConstraintLayout() {
    ConstraintLayout {
        val text = createRef()
        val guideline = createGuidelineFromStart(fraction = 0.5f)
        Text(
            text = "This is a very very very very very long text",
            modifier = Modifier.constrainAs(text) {
                start.linkTo(guideline)
            }
        )
    }
}

此时文字会超出屏幕展示区域:

2024-9-20.约束布局长文字放不下

可以通过为 Text 的宽度赋值为 Dimension.preferredWrapContent 解决该问题:

@Composable
fun LargeConstraintLayout() {
    ConstraintLayout {
        val text = createRef()
        val guideline = createGuidelineFromStart(fraction = 0.5f)
        Text(
            text = "This is a very very very very very long text",
            modifier = Modifier.constrainAs(text) {
                start.linkTo(guideline)
                width = Dimension.preferredWrapContent
            }
        )
    }
}

课程演示确实生效了,Text 的内容会自动换行,但是我测试的时候就不行,不知道为啥,先不纠结,继续向下了。

5.2 解耦 API

某些情况下,最好将约束条件与应用它们的布局分离。例如,根据屏幕配置来更改约束条件,或在两个约束条件集之间添加动画效果:

  • 将 ConstraintSet 作为参数传递给 ConstraintLayout
  • 使用 layoutId 修饰符将在 ConstraintSet 中创建的引用分配给可组合项

还是来看例子:

@Composable
fun DecoupleConstraintLayout1() {
    val margin = 16.dp
    ConstraintLayout {
        val (button, text) = createRefs()

        Button(
            onClick = { },
            modifier = Modifier.constrainAs(button) {
                top.linkTo(parent.top, margin = margin)
            }
        ) {
            Text(text = "Button")
        }

        Text(
            text = "Text",
            modifier = Modifier.constrainAs(text) {
                top.linkTo(button.bottom, margin = margin)
                centerHorizontallyTo(parent)
            }
        )
    }
}

这是我们之间举过的一个简单示例,Text 在 Button 下面且水平居中。在描述两个组件之间的距离时,通过 margin 属性指定为变量 margin = 16dp。假如我现在横屏了,那么这个 margin 的距离势必要变大,而变量 margin 使用硬编码已经写为 16dp,那么如何去解耦才能满足横屏条件呢?

首先,我们定义一个函数,返回 ConstraintSet,将所有组件之间的约束关系定义在这个 ConstraintSet 内:

private fun decoupledConstrains(margin: Dp): ConstraintSet {
    return ConstraintSet {
        val button = createRefFor("button")
        val text = createRefFor("text")
        constrain(button) {
            top.linkTo(parent.top, margin)
        }
        constrain(text) {
            top.linkTo(button.bottom, margin)
            centerHorizontallyTo(parent)
        }
    }
}

createRefFor() 传入的是组件 id,在编写 UI 代码时,组件就是通过 id 找到相应的约束关系的:

@Composable
fun DecoupleConstraintLayout2() {
    BoxWithConstraints {
        val constraints = if (maxWidth < maxHeight) {
            // 宽比高小,说明是竖屏
            decoupledConstrains(margin = 16.dp)
        } else {
            // 宽比高大,说明是横屏
            decoupledConstrains(margin = 80.dp)
        }

        // ConstraintLayout 通过参数指定约束关系
        ConstraintLayout(constraints) {
            Button(
                onClick = { },
                // Button 通过修饰符与 id button 绑定
                modifier = Modifier.layoutId("button")
            ) {
                Text(text = "Button")
            }

            Text(
                text = "Text",
                // Text 通过修饰符与 id text 绑定
                modifier = Modifier.layoutId("text")
            )
        }
    }
}

这样你能看到,横屏时,组件之间的距离确实要比竖屏时要大,并且确实做到了将约束与布局分离。

decouple 的中文释义就是解耦。

6、Intrinsics

“Intrinsics” 这个词本身指内在特性或固有属性,在计算机科学和软件开发领域有多种意义,具体含义取决于上下文。以下是一些可能的解释:

  1. Intrinsics Functions:在编程中,“intrinsics”(内置函数)通常指的是由编译器或运行时库提供的一组特殊函数,这些函数在底层由硬件支持或被高度优化以提供更高效的执行。这些函数通常用于执行特定的低级操作,如位操作或数学运算。
  2. Intrinsics in UI Layouts:在UI开发中,“intrinsics”(固有尺寸)通常指的是视图或组件的固有尺寸,即它们在没有任何约束时自然采用的大小。在布局过程中,系统可能会使用这些固有尺寸来决定如何放置和调整视图。
  3. Intrinsics in Optimization:在编译器优化中,“intrinsics” 可能指的是一些特殊的优化函数或指令,用于执行特定的优化操作,例如内存访问模式的优化或特定平台的优化。

我们这里说的是第 2 点。

Compose 只测量子元素一次,测量两次会引发运行时异常。但是,有时在测量子元素之前,需要一些有关子元素的信息。这时 Intrinsics 就派上了用场,它允许在实际测量之前查询子项:

  • (min|max)IntrinsicWidth:鉴于此高度,可以正确绘制内容的最小/最大宽度是多少
  • (min|max)IntrinsicHeight:鉴于此宽度,可以正确绘制内容的最小/最大高度是多少

举例,比如我们要做下面这种效果:

2024-9-21.Intrinsics演示

两个 Text 位于 Row 的首尾两端,中间有一条分隔线,要求其高度刚好是 Text 的高度。

这个要求最大的问题就在于如何在测量之前就知道 Text 的高度呢?如果不使用 Intrinsics,我们当然不知道,就只能这样做:

@Composable
fun TwoTexts(modifier: Modifier = Modifier) {
    Row(modifier = modifier) {
        Text(
            text = "Hi",
            modifier = Modifier
                .weight(1f)
                .padding(start = 4.dp)
                .wrapContentWidth(Alignment.Start)
        )

        Divider(
            color = Color.Black,
            modifier = Modifier
                .fillMaxHeight()
                .width(1.dp)
        )

        Text(
            text = "There",
            modifier = Modifier
                .weight(1f)
                .padding(end = 4.dp)
                .wrapContentWidth(Alignment.End)
        )
    }
}

给 Divider 的修饰符指定 fillMaxHeight(),那么毫无疑问,它会占满整个屏幕:

2024-9-21.Intrinsics演示1

因此我们必须要用到 Intrinsics 为 Row 指定最小高度,也就能刚好放下两个 Text 的高度:

/**
 * wrapContentWidth:允许内容按照其期望的宽度进行测量,而不考虑传入的最小宽度约束,如果未受限制(unbounded 为
 * true),也不考虑传入的最大宽度约束。如果内容的测量尺寸小于最小宽度约束,则将其对齐在该最小宽度空间内。
 * 如果内容的测量尺寸大于最大宽度约束(仅在 unbounded 为 true 时可能),则对齐在最大宽度空间之上。
 */
@Composable
fun TwoTexts(modifier: Modifier = Modifier) {
    Row(modifier = modifier
        // 指定 Row 的最小高度
        .height(IntrinsicSize.Min)
        .background(Color.LightGray)) {
        Text(
            text = "Hi",
            modifier = Modifier
                .weight(1f)
                .padding(start = 4.dp)
                .wrapContentWidth(Alignment.Start)
        )

        Divider(
            color = Color.Black,
            modifier = Modifier
                .fillMaxHeight()
                .width(1.dp)
        )

        Text(
            text = "There",
            modifier = Modifier
                .weight(1f)
                .padding(end = 4.dp)
                .wrapContentWidth(Alignment.End)
        )
    }
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值