大话Compose炼体(2)-把三碗饭吐出来

893 阅读14分钟

前言

在上篇 大话Compose炼体(1) 中,借助ModalNavigationDrawer{}+Scaffold{}布局脚手架快速搭建了一个带侧边抽屉,顶部应用栏等组件的一个常规首页。然而实际中的大多数情况,我们面对的非常规页面居多,这就需要我们具备一定的能力去做自定义适配。正好Scaffold本身其实就是一个很好的自定义布局的实现。所以本篇最后会通过改写Scaffold的源码来实现一个Snackbar显示在FAB下面的自定义Scaffold

在开始之前,需要了解以下几个知识点:

WindowInsets

什么是WindowsInsets?我们拆开来看,先看Window,在android中Window抽象类的实例是添加到window manager的顶级view,Window负责顶级窗口外观和行为策略,提供标准的 UI 策略,例如背景、标题区域、默认键处理等。framework会代表应用程序实例化这个类的实现。再来看Insets,Inset的复数,Inset翻译过来是插入物。合起来理解一下,WindowInsets就是应用程序顶级view的一组插入物。插入物有哪些?可以理解为不是应用程序产出的视图和手势都属于插入物!例如状态栏,虚拟导航栏,系统软键盘,系统全局手势导航,刘海屏的剪切区域都属于WindowsInsets。

WindowInsets有什么用?它们包含着插入物的宽高信息,利用这些信息我们可以最大程度的解决应用程序的内容和这些插入物的覆盖或冲突问题。

来看下面这个简单的例子

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    WindowCompat.setDecorFitsSystemWindows(window, false)
    setContent {
        val systemUiController = rememberSystemUiController()
        val useDarkIcons = !isSystemInDarkTheme()
        LaunchedEffect(systemUiController, useDarkIcons) {
            systemUiController.setSystemBarsColor(
                color = Color.Transparent,
                darkIcons = useDarkIcons,
            )
        }
        WaTheme {
            Box(modifier = Modifier.background(alpha=0.4f, brush = SolidColor(Color.Blue)).fillMaxSize()) {
                Text(
                    text = "我如果不够长你就看不清我了!",
                    modifier = Modifier.background(Color.Yellow),
                )
            }
        }

    }
}
运行后很明显的textview的内容和状态栏重叠了。

这里如果知道状态栏的高度就好了。textview空出这个距离不就可以了么?状态栏无疑也是windowInset的一种,那么如何通过windowsInsets拿到呢?

先来整理一下compose里面对windowsInsets的分类:

  1. 剪切区域:刘海屏裁切区域
  2. 可点击元素:触摸角
  3. 强制系统手势:系统强制处理的触摸区域
  4. 系统手势:系统处理的触摸区域
  5. 输入法:软键盘
  6. 标题栏:手机上不确定有没有,好像是tv端的(不确定)
  7. 导航栏:底部的3金刚虚拟导航栏或者全面屏的药丸导航栏
  8. 状态栏:顶部的状态栏
  9. 瀑布:曲面屏的侧面(瀑布这个形容不错!)
  10. 系统栏:状态栏+导航栏+标题栏
  11. 安全绘制:系统栏+剪切区域+软键盘
  12. 安全手势:系统手势+系统强制手势+瀑布滑动+触摸角
  13. 安全内容:安全绘制+安全手势

那么要解决上面的问题我们只用选择状态栏即可

Text(
    text = "我如果不够长你就看不清我了!",
    modifier = Modifier.background(Color.Yellow).windowInsetsPadding(WindowInsets.statusBars),
)

windowInsetsPadding(WindowInsets.statusBars)效果符合预期

再来换安全内容来试试效果

Text(
    text = "我如果不够长你就看不清我了!",
    modifier = Modifier.background(Color.Yellow).windowInsetsPadding(WindowInsets.safeContent),
)

windowInsetsPadding(WindowInsets.safeContent)符合预期,左右两边空出来了系统侧滑返回的距离,上面空出了状态栏的距离,下面空出了药丸的距离。

布局模型

Compose呈现在页面上有3个步骤,组合->布局->绘制,其中在界面树中布局每个节点的过程又分为三个步骤。每个节点必须:

  1. 测量所有子项
  2. 确定自己的尺寸
  3. 放置其子项 layout-three-step-process.svg

在以上布局模型中,通过单次传递即可完成界面树布局。首先,系统会要求每个节点对自身进行测量,然后以递归方式完成所有子节点的测量,并将尺寸约束条件沿着树向下传递给子节点。再后,确定叶节点的尺寸和放置位置,并将经过解析的尺寸和放置指令沿着树向上回传。

简而言之,父节点会在其子节点之前进行测量,但会在其子节点的尺寸和放置位置确定之后再对自身进行调整。

请参考以下 SearchResult 函数。

@Composable
fun SearchResult(...) {
  Row(...) {
    Image(...)
    Column(...) {
      Text(...)
      Text(..)
    }
  }
}

此函数会生成以下界面树。

SearchResult
  Row
    Image
    Column
      Text
      Text

在 SearchResult 示例中,界面树布局遵循以下顺序:

  1. 系统要求根节点 Row 对自身进行测量。
  2. 根节点 Row 要求其第一个子节点(即 Image)进行测量。
  3. Image 是一个叶节点(也就是说,它没有子节点),因此该节点会报告尺寸并返回放置指令。
  4. 根节点 Row 要求其第二个子节点(即 Column)进行测量。
  5. 节点 Column 要求其第一个子节点 Text 进行测量。
  6. 由于第一个节点 Text 是叶节点,因此该节点会报告尺寸并返回放置指令。
  7. 节点 Column 要求其第二个子节点 Text 进行测量。
  8. 由于第二个节点 Text 是叶节点,因此该节点会报告尺寸并返回放置指令。
  9. 现在,节点 Column 已测量其子节点,并已确定其子节点的尺寸和放置位置,接下来它可以确定自己的尺寸和放置位置了。
  10. 现在,根节点 Row 已测量其子节点,并已确定其子节点的尺寸和放置位置,接下来它可以确定自己的尺寸和放置位置了。

search-result-layout.svg

Layout可组合项

Layout可组合项是布局环节中重要的核心组成。此可组合项允许手动测量布置子项。Column 和 Row 等所有较高级别的布局都使用 Layout 可组合项构建而成。

我们来构建一个非常基本的 Column。大多数自定义布局都遵循以下模式:

@Composable
fun MyBasicColumn(
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {
    Layout(
        modifier = modifier,
        content = content
    ) { measurables, constraints ->
        // 在这处理用提供的约束条件去测量和布置子项的逻辑
    }
}

measurables是装着所有需要测量的子项的列表,而constraints是来自父项的约束条件。按照与前面相同的逻辑,可按如下方式实现 MyBasicColumn

@Composable
fun MyBasicColumn(
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {
    Layout(
        modifier = modifier,
        content = content
    ) { measurables, constraints ->
        //返回所有子项在父布局中的可放置布局
        val placeables = measurables.map { measurable ->
            // 在约束条件下测量每个子项
            measurable.measure(constraints)
        }
        
        // 一般在约束条件下能设置多大就设置多大
        //不过这两个参数会影响父布局的大小,有时候要按需传递
        layout(constraints.maxWidth, constraints.maxHeight) {
            // y轴位置坐标
            var yPosition = 0

            // 在父布局布置子项
            placeables.forEach { placeable ->
                placeable.placeRelative(x = 0, y = yPosition)
                // 每布置一个子项后递增y坐标
                yPosition += placeable.height
            }
        }
    }
}

可组合子项受 Layout 约束条件(没有 minHeight 约束条件)的约束,它们的放置基于前一个可组合项的 yPosition

把下面的代码放在上一篇的示例代码的Scaffold的content块下:

MyBasicColumn(Modifier.padding(padding)) {
    Text("MyBasicColumn")
    Text("places items")
    Text("vertically.")
    Text("We've done it by hand!")
}

看看上面的MyBasicColumn可组合项中的自定义布局的layout(constraints.maxWidth, constraints.maxHeight) {}函数传入的宽高参数,因为我们父布局没有限制约束,所以这个时候的layout的宽高是能多大就取多大。我们给它加个边框来看看效果。

MyBasicColumn(Modifier.padding(padding).border(1.dp, Companion.Red)) {
    Text("MyBasicColumn")
    Text("places items")
    Text("vertically.")
    Text("We've done it by hand!")
}

发现果然是把宽高设置成了能用的最大范围(因为系统栏的padding,减去了topbar和bottombar的高度)。但是Compose给我们提供的Column可组合项却没有这个问题,核心逻辑其实也就是在调用layout(width,height){放置子项逻辑}前,测量所有子项,然后传入通过所有子项宽高计算出来的值来指定layout的宽高。我们稍微修改一下也能实现类似wrapcontent的效果出来。

@Composable
fun MyBasicColumn(
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {
    Layout(
        modifier = modifier,
        content = content
    ) { measurables, constraints ->
        //用来记录所有子项的高
        var maxHeight=0
        //用来记录子项最大宽度
        var maxWeight=0
        val placeables = measurables.map { measurable ->
            measurable.measure(constraints).apply {
                //测量每个子项的时候累加高
                maxHeight+=height
                //测量每个子项的时候取宽的最大值
                maxWeight=width.coerceAtLeast(maxWeight)
            }
        }
         //指定我们通过子项计算出来的宽高
        layout(maxWeight, maxHeight) {
            var yPosition = 0
            //放置子项,并最终确认自身的布局大小
            placeables.forEach { placeable ->
                placeable.placeRelative(x = 0, y = yPosition)
                yPosition += placeable.height
            }
        }
    }
}

运行一下看看效果,符合预期了吧

固有特性测量

除了上面那种直接设置layout宽高的值来指定布局的宽高外,其实还可以借用Compose中的固有特性测量来实现同样的效果。不同的是,通过固有特性测量我们可以在进行正式测量之前就获取到子项的宽高等信息。这样有个好处是很多时候我们可以直接通过父布局的宽高来控制子项的宽高 (子项用fillMax相关api直接适配)。在实现前先简单认识一下固有特性的几个关键概念。

/**
 * 1.参数限制的高度下,计算出能让内容正确显示的最小宽度 
 */
fun minIntrinsicWidth(height: Int): Int

/**
 * 2.计算出增加宽度也不会减少高度的最小宽度 
 */
fun maxIntrinsicWidth(height: Int): Int

/**
 * 3.参数限制的宽度下,计算出能让内容正确显示的最小高度
 */
fun minIntrinsicHeight(width: Int): Int

/**
 * 4.计算出增加高度也不会减少宽度的最小高度
 */
fun maxIntrinsicHeight(width: Int): Int

说实话,有点不好理解。可能大家或多或少会有这样的疑问。为什么max min开头的方法返回的都是最小的宽或者高呢?它们的区别又是什么呢? 下面我们通过改造一下前面的例子来帮助理解:


MyBasicColumn(
    Modifier
        .padding(top =padding.calculateTopPadding())
        .height(Min)
        .width(Min)
        .border(1.dp, Companion.Red),
) {
    Text("MyBasicColumn")
    Text("places items")
    Text("vertically.")
    Text("We've done it by hand!")
}


//用固有特性测量实现
@Composable
fun MyBasicColumn(
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit,
) {
    Layout(
        modifier = modifier,
        content = content,
        measurePolicy = object : MeasurePolicy {
            override fun MeasureScope.measure(
                measurables: List<Measurable>,
                constraints: Constraints,
            ): MeasureResult {
                //测量和布置逻辑
                val placeables = measurables.map { measurable ->
                    // Measure each children
                    measurable.measure(constraints.copy(minWidth = 0, minHeight = 0))
                }
                return layout(constraints.maxWidth, constraints.maxHeight) {
                    var yPosition = 0

                    placeables.forEach { placeable ->
                        placeable.placeRelative(x = 0, y = yPosition)
                        yPosition += placeable.height
                    }
                }
            }

            override fun IntrinsicMeasureScope.minIntrinsicHeight(
                measurables: List<IntrinsicMeasurable>,
                width: Int,
            ): Int {
                var maxHeight = 0

                measurables.map {
                    //1.通过所有子项的固有特性计算,累加后返回为父布局的约束高
                    maxHeight += it.minIntrinsicHeight(width)
                }
                return maxHeight
            }

            override fun IntrinsicMeasureScope.minIntrinsicWidth(
                measurables: List<IntrinsicMeasurable>,
                height: Int,
            ): Int {
                var maxWidth = 0

                measurables.map {
                    //2.通过所有子项的固有特性计算,取最大值返回为父布局的约束宽
                    maxWidth = it.maxIntrinsicWidth(height).coerceAtLeast(maxWidth)
                }
                return maxWidth
            }
        },
    )
}

运行后效果跟之前的一模一样。

注意:注释1的子项调用的是minIntrinsicHeight,注释2子项调用的是maxIntrinsicWidth 看看把注释2的调用改成minIntrinsicWidth的效果

哎!"We've done it by hand!"的Text子项怎么换行了!?是父布局的高算错了么?并不是!因为这个高度完全可以正确显示的所有子项的内容。text内容最长的子项换行才是导致了高度看上去不对的原因!那么为什么它会换行呢?
如图所示,绿色框是当前子项的宽高范围。

Modifier函数的顺序非常重要。由于每个函数都会对上一个函数返回的 Modifier 进行更改,我们先调用的height(Min)

MyBasicColumn(
    Modifier
        .padding(top =padding.calculateTopPadding())
        .height(Min)//先调用
        .width(Min)//后调用
        .border(1.dp, Companion.Red),
)

获取固有特性测量的最小高的时候,width没有任何限制,然后通过计算所有子项固有特性的高得到父布局可以正确显示所有子项的最小高,如红色框所示。后调用width(Min)再去计算固有特性的宽,这个时候方法里面的height就不是无限制了,而是前面算出来的值了,所以在这个高度的约束下,这时如果子项用minIntrinsicWidth,那么获取的是在父布局约束的高度内能正确显示的内容的最小宽也就是绿色框的宽。再来看一下maxIntrinsicWidth的注释

/** * 2.计算出增加宽度也不会减少高度的最小宽度 */ 
fun maxIntrinsicWidth(height: Int): Int

那么请想象一下。如果把绿框的宽度增加到一定宽度,是不是 "by hand!"就可以在“ We've done it”这一行接着显示了!带来的影响是什么呢?是不是框的高度变了?如果此时再增加宽度,也没有什么意义了,因为高度不会再变了!而刚刚能让父布局高度不变并能正确显示子项内容的宽就是最小宽!

现在我们可以回答开头提出的问题了。maxIntrinsicWidth or maxIntrinsicHeight计算的值,其实就是父布局高度或者宽度不变时子项的最小宽或者最小高。而minIntrinsicWidthor minIntrinsicHeight计算的是值,是当前父布局的约束条件下能正确显示子项内容的最小宽高。

所以我们用maxIntrinsicWidth来父布局高度不变时的子项的最小宽,来实现预期效果。

固有特性测量并不复杂,就是开始理解起来的时候容易造成混淆和困惑。建议仔细看示例代码,这玩意懂了也就懂了,多看几遍想通后自然就掌握了。

SubcomposeLayout

到这一步的时候估计也吐的差不多了。我们缓一下,捋一捋上面的内容。

  1. 自定义布局起手来个Layout函数,我们可以先测量子项,然后再在layout()的时候设置父布局的宽高并完成子项的布置
  2. 自定义布局起手来个Layout函数,我们可以通过固有特性测量获取子项的宽高等信息,然后返回父布局的宽高,然后子项完成真正的测量布置

这两种方法都是通过先获取子项的宽高来计算父布局的宽高,简单的父与子的双向关系。假设父布局的部分子项在测量布置的时候需要根据其他子项的测量结果来决定呢?就好比Scaffold中,FAB是如何做到一定会显示在bottomBar上面?Snackbar又一定会显示在FAB上面?要回答这些问题就必须让SubcomposeLayout出场了!

SubcomposeLayout允许子项的组合过程延迟到父组件实际发生测量时机进行,为我们提供了更强的测量定制能力。怎么理解?一句话可以概括为:SubcomposeLayout让其子项的组合过程可以在自身的layout(width,height){}函数内,通过subcompose(soltId,content)组合延迟发生。Scaffold的实现正是利用了SubcomposeLayout这一特性。

自定义Scaffold

终于可以开始正题了!如果前面的内容如果都弄明白了,其实到这里就很简单了。几乎照搬了Scaffold源码,只是稍作了修改实现。我们直接上关键代码:

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun WAScaffold(
    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) {
        SubcomposeLayout { constraints ->
            val layoutWidth = constraints.maxWidth
            val layoutHeight = constraints.maxHeight

            val looseConstraints = constraints.copy(minWidth = 0, minHeight = 0)

            layout(layoutWidth, layoutHeight) {
                  ...省略
                //组合Snackbar并测量,返回一个测量结果。
                val snackbarPlaceables =
                    subcompose(ScaffoldLayoutContent.Snackbar, snackbarHost).map {
                        // respect only bottom and horizontal for snackbar and fab
                        val leftInset = contentWindowInsets
                            .getLeft(this@SubcomposeLayout, layoutDirection)
                        val rightInset = contentWindowInsets
                            .getRight(this@SubcomposeLayout, layoutDirection)
                        val bottomInset = contentWindowInsets.getBottom(this@SubcomposeLayout)
                        // offset the snackbar constraints by the insets values
                        it.measure(
                            looseConstraints.offset(
                                -leftInset - rightInset,
                                -bottomInset,
                            ),
                        )
                    }

                val snackbarHeight = snackbarPlaceables.maxByOrNull { it.height }?.height ?: 0
                val snackbarWidth = snackbarPlaceables.maxByOrNull { it.width }?.width ?: 0

                
                //组合bottomBar并测量
                val bottomBarPlaceables = subcompose(ScaffoldLayoutContent.BottomBar) {
                    CompositionLocalProvider(
                        LocalFabPlacement provides fabPlacement,
                        content = bottomBar,
                    )
                }.map { it.measure(looseConstraints) }
                
                //bottomBar高度
                val bottomBarHeight = bottomBarPlaceables.maxByOrNull { it.height }?.height
                //计算出Snackbar底部偏移量
                val snackbarOffsetFromBottom = if (snackbarHeight != 0) {
                    //自身的高度+bottomBar的高度,如果没有bottomBar,那么就是自身+系统栏底部的插边高度
                    snackbarHeight +
                        (bottomBarHeight ?: contentWindowInsets.getBottom(this@SubcomposeLayout))
                } else {
                    //不显示高度为0
                    0
                }
                //计算是FAB底部偏移量
                val fabOffsetFromBottom = fabPlacement?.let {
                    if (snackbarOffsetFromBottom == 0) {
                        it.height + FabSpacing.roundToPx() + (bottomBarHeight
                            ?: contentWindowInsets.getBottom(this@SubcomposeLayout))
                    } else {
                        snackbarOffsetFromBottom + it.height + FabSpacing.roundToPx()
                    }
                }

               ...省略
                snackbarPlaceables.forEach {
                    it.place(
                        (layoutWidth - snackbarWidth) / 2 +
                            contentWindowInsets.getLeft(this@SubcomposeLayout, layoutDirection),
                        layoutHeight - snackbarOffsetFromBottom,
                    )
                }
                // The bottom bar is always at the bottom of the layout
                bottomBarPlaceables.forEach {
                    it.place(0, layoutHeight - (bottomBarHeight ?: 0))
                }
                
                fabPlacement?.let { placement ->
                    fabPlaceables.forEach {
                        it.place(placement.left, layoutHeight - fabOffsetFromBottom!!)
                    }
                }
            }
        }

    }
}

这样就实现了一个Snackbar永远在FAB下显示的自定义Scaffold了

附完整代码地址:大话Compose炼体(2)相关类 (github.com)

本文内容部分参考和引用以下链接

Compose 布局中的固有特性测量  |  Jetpack Compose  |  Android Developers

自定义布局  |  Jetpack Compose  |  Android Developers

固有特性测量 | 你好 Compose (jetpackcompose.cn)