自定义布局在 Compose 中相对于原生的需求已经小了很多,先讲二者在本质上的逻辑,再说它们的使用场景,两相对比就知道为什么 Compose 中的自定义布局的需求较小了。
原生是在 xml 布局文件不太方便或者无法满足需求时才会在代码中通过自定义 View 的方式,重写 onMeasure() 或 onLayout(),或添加其他功能方法来实现需求。
原生的自定义 View 通常会有两种场景:
- xml 布局提供的属性不够方便,需要通过继承组件的方式,在子类中添加一些函数使得用起来更方便
- xml 布局无法满足需求,需要继承 View 或 ViewGroup 并重写 onMeasure() 或 onLayout() 重新实现一个自定义 View 来满足需求
到了 Compose 中,没有 xml 了,布局本身就是在代码中通过 Composable 函数实现的,原生的两种场景中的第一种情况在 Compose 中没有谈论的意义,所以 Compose 中的自定义布局可以认为只是针对布局算法,对 onMeasure() 与 onLayout() 在 Compose 中的等价物提供自定义实现。
1、Layout()
前面我们讲过使用 Modifier.layout() 对组件自身的尺寸进行修改,一定注意它只能影响它自身,无法影响它内部组件的测量与布局。假如想实现对内部组件的测量与布局算法,需要使用 Layout() 这个 Composable 函数:
@Suppress("ComposableLambdaParameterPosition")
@UiComposable
@Composable inline fun Layout(content: @Composable @UiComposable () -> Unit,modifier: Modifier = Modifier,measurePolicy: MeasurePolicy
)// 这个没有 content 的版本适用于内部没有子组件的布局摆放,但实际上,这种情况下
// 使用 Modifier.layout() 要更方便一些
@Suppress("NOTHING_TO_INLINE")
@Composable
@UiComposable
inline fun Layout(modifier: Modifier = Modifier,measurePolicy: MeasurePolicy
)
Layout() 有两个版本,差在第一个参数 content 上。content 是待测量与布局的 Composable 组件,第三个参数 measurePolicy 是 MeasurePolicy 接口的实例,内部只有一个接口函数:
@Stable
@JvmDefaultWithCompatibility
fun interface MeasurePolicy {fun MeasureScope.measure(measurables: List<Measurable>,constraints: Constraints): MeasureResult
}
所以使用 Layout() 时可以对 measurePolicy 参数做 SAM 转换,直接写成尾随 lambda 函数的形式:
// 相当于自定义 ViewGroup,content 是需要被测量与摆放的子组件
@Composable
fun CustomLayout(modifier: Modifier = Modifier, content: @Composable () -> Unit) {Layout(content, modifier) {// 这里写对 content 的测量逻辑代码...// 由于 MeasurePolicy 的 measure() 的返回结果是 MeasureResult 类型的,// 因此最后要返回一个 MeasureResult,通常是用 layout() 作为结果返回layout()}
}
上面的代码实际上是一个通用的自定义布局的代码模板,CustomLayout() 的内部直接用 Layout() 包住其余代码,不要在同级调用其他 Composable 函数,因为那样会导致 Layout() 无法对它们进行测量与布局。Layout() 的尾随 lambda 实际上是 MeasurePolicy 的 measure(),先写测量逻辑再在 layout() 内写布局逻辑,直接用 layout() 作为返回结果即可。
下面通过一个简单的例子具体说明一下如何使用 Layout() 实现自定义布局,比如想要实现一种类似 Column 的测量布局逻辑,它的示例效果如下:
那么 CustomLayout 应该这样实现:
@Composable
fun CustomLayout(modifier: Modifier = Modifier, content: @Composable () -> Unit) {Layout(content, modifier) { measurables, constraints ->var width = 0var height = 0// 遍历可测量组件集合 measurables,对每一个 measurable 进行测量 val placeables = measurables.map { measurable ->measurable.measure(constraints).also { placeable ->// 高度叠加,宽度取宽度最大的子组件宽度 width = max(width, placeable.width)height += placeable.height}}// 先给 layout() 参数传入本组件测量的最终宽高,然后遍历测量得到的 placeables // 集合,调用 placeable 的 placeRelative 指定每一个子组件的摆放位置layout(width, height) {var totalHeight = 0placeables.forEach {it.placeRelative(0, totalHeight)totalHeight += it.height}}}
}
然后使用三个 Box 对 CustomLayout 进行测试:
@Preview
@Composable
fun CustomLayoutPreview() {CustomLayout {Box(Modifier.size(80.dp).background(Color.Red))Box(Modifier.size(80.dp).background(Color.Green))Box(Modifier.size(80.dp).background(Color.Blue))}
}
结果就如上面的结果图那样。这只是一个很简单的例子,没有考虑的很精细,当要真正的实现一个高质量的自定义布局组件时,至少还需要考虑到 weight 等因素。
2、SubcomposeLayout()
相比与此前我们接触过的“一般”的 Compose 组件,SubcomposeLayout() 更高级、功能更强大,我们熟悉的滑动列表 LazyColumn/LazyRow 内部就使用了它。但是这种强大的功能是需要以性能损耗作为代价的,因此要挑选适合的应用场景来使用它。本节我们就来介绍 SubcomposeLayout() 的作用、用法与使用场景。
2.1 BoxWithConstraints()
我们先从封装了 SubcomposeLayout() 的 BoxWithConstraints() 开始说起,结合实际需求来说明 SubcomposeLayout() 的一个相对简单的使用场景。
假如现在有一个需求,需要根据父组件对当前组件的测量约束,决定使用具体使用哪些 Composable 组件函数。比如,宽度约束大于 1000 使用 Layout1,否则使用 Layout2,那么我们可以写出如下的伪代码:
if (widthCondition) {Layout1()
} else {Layout2()
}
那问题在于,widthCondition 应该怎么写,如何在当前组件中获取到父组件对当前组件的测量约束呢?联想前面所学的知识,一个普通组件,只有在测量阶段才能获取到父组件对它的测量约束:
// constraints 为父组件对 Box 的测量约束
Box(Modifier.layout { measurable, constraints -> }) {// 组合阶段代码if (widthCondition) {Layout1()} else {Layout2()}
}
但是我们无法将测量阶段的 constraints 无法被应用于组合阶段的 widthCondition 中。因为,Compose 的 UI 显示过程,相对于 View 系统的测量、布局、绘制会多出一步组合(Composition)。组合就是调用界面上的 Composable 函数形成描述界面元素的节点(老版本是 LayoutNode,新版本是 NodeCoordinator),也就是说 Compose 显示 UI 的过程是组合 -> 测量 -> 布局 -> 绘制这四个阶段,这四个阶段在执行时互相独立,没有穿插的部分。因此,组合阶段的条件 widthCondition 无法获取到它之后的测量阶段的 constraints。
为了解决这个问题,可以使用 BoxWithConstraints():
@Composable
@UiComposable
fun BoxWithConstraints(modifier: Modifier = Modifier,contentAlignment: Alignment = Alignment.TopStart,propagateMinConstraints: Boolean = false,content:@Composable @UiComposable BoxWithConstraintsScope.() -> Unit
) {val measurePolicy = rememberBoxMeasurePolicy(contentAlignment, propagateMinConstraints)SubcomposeLayout(modifier) { constraints ->val scope = BoxWithConstraintsScopeImpl(this, constraints)val measurables = subcompose(Unit) { scope.content() }with(measurePolicy) { measure(measurables, constraints) }}
}
可以看到 BoxWithConstraints() 内部是使用了 SubcomposeLayout() 的,并且把子组件内容 content 交给 SubcomposeLayout() 去进行测量。这样做之后,content 这部分组件的组合过程就被延后到测量阶段了,也就可以拿到测量阶段的数据 constraints 了。BoxWithConstraints() 相对于 Box() 一个最显著的区别也就是这一点,前者可以在组合阶段的代码中拿到它的父组件对它的测量约束 constraints,而像 Box()、Column() 这种“普通的”组件,只能在测量阶段,也就是 Modifier.layout() 中通过参数获取到 constraints。
必须要认清的一点是,BoxWithConstraints() 是把子组件的组合代码延迟到测量阶段才执行,因此得以获取测量阶段的数据,而不是像看起来的那样,BoxWithConstraints() 可以在组合阶段拿到测量阶段的约束条件。
这样,使用 BoxWithConstraints() 就可以完成上面的需求:
BoxWithConstraints {if (constraints.maxWidth > 1080) {Layout1()} else {Layout2()}
}
总结:BoxWithConstraints() 内的代码确实是组合行为,但是它们发生在测量阶段,而不是组合阶段。即组合代码发生在测量阶段,而不是组合阶段。
2.2 SubcomposeLayout() 的作用与弊端
Subcompose 可以理解为一个次级组合,它的流程是独立的,但在结构上又归属于整体的那个组合。SubcomposeLayout() 可以让一部分的组合过程延后到测量甚至是布局阶段,这样可以在被延迟部分的组合过程中拿到测量阶段的数据。因为正常情况下,Compose 显示 UI 的四步是组合、测量、布局、绘制,这四步是相互独立,没有交叉部分的。因此组合一定发生在测量之前,故组合拿不到测量阶段才有的数据。将部分组合内容推迟到测量阶段后,就可以拿到测量阶段的数据了。
这样做好处是什么?有什么用途?实际上,上一节我们举得例子已经说明了,或者说它可以解决哪些问题,就是动态布局。
动态布局就是指布局内容不是一开始就完全固定写好的,而是会根据上下文内容动态决定布局内容。比如那个例子中要求的,根据父组件的宽度决定具体使用哪一个布局,就是动态决定了 BoxWithConstraints() 内子组件的布局内容。再比如,应用的界面可能会根据不同的节日展现不同的主题或布局,这也是动态布局。
动态布局的实现方式有多种,SubcomposeLayout() 只是其中之一。
来看 SubcomposeLayout() 的参数:
/**
* [Layout] 的类似物,允许在测量阶段对实际内容进行子组合,例如使用测量期间计算的值作为子元素组合的参数。
* 可能的用例:
* 1.您需要知道父级在组合期间传递的约束,并且无法仅通过自定义 [Layout] 或 [LayoutModifier] 解决您的
* 用例。请参阅 [androidx.compose.foundation.layout.BoxWithConstraints]。
* 2.您希望在组合第二个子元素时使用一个子元素的大小。
* 3.您希望根据可用大小懒惰地组合您的项目。例如,您有一个包含100个项目的列表,而不是组合所有项目,
* 您仅组合当前可见的项目(例如其中的5个项目),并在组件滚动时组合下一个项目。(LazyColumn)
*/
@Composable
fun SubcomposeLayout(modifier: Modifier = Modifier,measurePolicy: SubcomposeMeasureScope.(Constraints) -> MeasureResult
) {SubcomposeLayout(state = remember { SubcomposeLayoutState() },modifier = modifier,measurePolicy = measurePolicy)
}
第二个参数 measurePolicy 其实前面我们说过很多次了,该接口只有一个 measure 函数用于指定组件内部的测量逻辑,通常使用 layout() 作为返回结果 MeasureResult。但这里的 measurePolicy 有一个特殊之处是它的接收者类型是 SubcomposeMeasureScope 而不是此前的 MeasureScope:
/**
* [SubcomposeLayout] 的测量 lambda 的接收者作用域,在 [MeasureScope] 提供的功能之上
* 增加了在测量过程中动态进行内容子组合的能力。
*/
interface SubcomposeMeasureScope : MeasureScope {/*** 使用给定的 [slotId] 对提供的 [content] 进行子组合。* * @param slotId 表示我们正在组合的插槽的唯一 id。如果您有固定数量的插槽,可以使用枚举* 作为插槽 id,或者如果您有一个项目列表,也许列表中的索引或其他唯一键可以起作用。为了能够* 正确匹配重新测量之间的内容,您应该提供与您在以前测量期间使用的相等的对象。* @param content 定义插槽的组合内容。它可以生成多个布局,在这种情况下,* 返回的 [Measurable] 列表将具有多个元素。*/fun subcompose(slotId: Any?, content: @Composable () -> Unit): List<Measurable>
}
因此通常会在 SubcomposeLayout() 的 measurePolicy 内先调用 subcompose() 传入需要被延迟组合的布局内容,得到一个 List<Measurable>
,然后根据业务需求对它进行测量与布局。大致的代码步骤如下:
SubcomposeLayout { constraints ->// subcompose() 的 content 参数才提供了 @Composable 环境,只有这里才能写布局内容val measurableList = subcompose(1) {Text(text = "Text")}// 假设仅对第一个组件进行测量与布局val placeable = measurableList[0].measure(constraints)layout(placeable.width, placeable.height) {placeable.placeRelative(0, 0)}
}
乍一看,使用 SubcomposeLayout() 需要写子组合、测量、布局的代码,非常麻烦。但实际上,这种“麻烦”是因为业务需求本身就足够复杂,使用封装了 SubcomposeLayout() 的那些简便函数(比如 BoxWithConstraints())无法满足业务需求,因此只能使用底层功能更全面、更强大,且能提供更灵活用法的 SubcomposeLayout()。
比如说,如果你只想拿到父组件对子组件的尺寸限制 constraints 的话,那么使用 BoxWithConstraints() 即可。但如果你的需求要复杂一些,比如要先对父组件内一部分子组件进行测量,然后根据测量结果决定子组件如何摆放,那么就需要使用 SubcomposeLayout()。
假如要实现一个功能,第二个组件要根据第一个组件的宽度决定是显示成一个图片还是一串文字。由于需要先对第一个组件进行测量,所以无法使用 BoxWithConstraints() 就可简单的完成需求,只能使用 SubcomposeLayout() 对组件先进行测量,然后根据测量结果动态决定第二个组件的形式,所以它的伪代码如下:
SubcomposeLayout { constraints ->val measurableList = subcompose(1) {Text(text = "Text")}val placeable = measurableList[0].measure(constraints)val measurables = if (placeable.width > 1000) subcompose(2) {Image(painter =, contentDescription =)} else {Text(text =)}val placeable2 = measurables.measure()layout(placeable2.width, placeable2.height) {placeable.placeRelative(0, 0)}
}
这样可以在第一个组件测量完成之后再决定后续的组件是什么,不仅可以延后组合过程,还可以随意穿插,本质上就是让一部分组合工作依赖于其他部分的测量结果。这就是 SubcomposeLayout() 需要我们在其内部既写组合,又要写测量与布局的原因,它能提供最大的灵活性。
事实上,Compose 提供的很多组件底层都用到了 SubcomposeLayout(),比如 material 库中的 Scaffold(),动态测量 top bar、bottom bar、snack bar、floating action button,滑动容器组件 LazyColumn() 与 LazyRow(),只测量需要显示出来的列表项,而不是测量所有列表项。
SubcomposeLayout() 相比于 BoxWithConstraints() 更底层,提供更强大的功能,布局的灵活性更强。那既然 BoxWithConstraints() 这么好,那就只用它不用 Box() 了呗?这是因为它有缺点,因此不能默认就用这个东西。
缺点是对界面性能有负面影响。Compose 的组合过程是有很多优化的,而对于 SubcomposeLayout() 而言,每一个 SubcomposeLayout() 内部都独立维护着一份节点树(slot table),这使得它在重组过程中,无法参与到整体的重组优化。
并且,从完整的工作流程上来说,UI 显示的过程是组合 -> 测量 -> 布局 -> 绘制,由于测量和布局在组合之后,因此重新进行测量与布局不会引发重组。但由于 SubcomposeLayout() 将组合延后到测量与布局阶段了,所以重新的测量与布局会引发重组,导致额外的性能消耗。
性能下降与性能风险体现在高频的重复测量导致高频的重组所引发的界面卡顿。比如在给 LazyColumn 的宽高执行放缩动画时就会出现这种情况,但话说回来,对 RecyclerView 的宽高执行同样的动画,它也会卡。而且相比于 LazyColumn 使用 SubcomposeLayout() 带来的性能提升,这种性能损耗相对就小一些。
总结就是平时能不用就不用,比如能用 Box() 实现的功能就不要用 BoxWithConstraints(),避免性能损耗。但是必须要用到时,还是可以放心大胆使用的,不要因噎废食。
3、LookaheadLayout()
LookaheadLayout 也可以在测量过程中拿到一些上下文信息,不过跟 SubcomposeLayout 不太一样。它在正式测量开始之前,会先进行一次预测量,根据预测量结果可以生成一些过渡动画,然后才进行正式测量。
LookaheadLayout 主要用途就是制作过渡动画,不过根据使用场景可以细分为两种:
- 过渡动画:组件在同一页面中,以动画形式展现位置与尺寸的变化
- 共享组件的过渡动画:类似于传统的 Transition API,有些组件在切换前后的页面都存在,这样的组件是共享组件。共享组件在两个页面的位置和大小可能不同,使用 Transition API 可以使共享组件以平滑的方式从一个页面切换到另一个页面中
3.1 预测量
前面的文章中,我们有提到过,Compose 是不允许同一个组件进行二次测量的,比如在如下的示例代码中:
@Composable
fun LayoutTest() {Layout({ Text("Compose") }) { measurables, constraints ->val placeables = measurables.map {// 正常测量一次即可,为了演示不让二次测量的后果,故意多测量一次it.measure(constraints)it.measure(constraints)}val width = placeables.maxOf { it.width }val height = placeables.maxOf { it.height }layout(width, height) {// 对于每一个 placeable 都防止在原位置,不进行偏移placeables.forEach { it.placeRelative(0, 0) }}}
}
Layout() 内在对组件进行遍历测量时,故意多执行了一次 measure(),运行时就会抛出异常:
FATAL EXCEPTION: main
Process: com.jetpack.compose, PID: 2604
java.lang.IllegalStateException: measure() may not be called multiple times on the same Measurable. Current state InMeasureBlock. Parent state Measuring.
异常信息指出,同一个 Measurable 不能调用多次 measure(),也就是不能进行多次测量。这样做的目的是通过强行的规则限制,迫使开发者简化自定义布局的逻辑,以此缩短布局过程的耗时。但实际上,这种限制只存在于 Layout() 对其内部子组件不能二次测量,而像 Modifier.layout() 这种针对单一组件的测量与布局,Compose 并没有限制它的测量次数。
LookaheadLayout 的预测量,与上面所说的二次测量不是同一个机制。look ahead 可以理解为前瞻,LookaheadLayout 就是一个包含前瞻性测量与布局的 Layout,它比普通的 Layout() 多了一个前瞻的过程,后续我们把这个前瞻测量统一称为预测量。预测量与布局在整体上与此前我们讲过的普通的测量与布局的过程是一样的,都是从外向内对每一个 Modifier 进行测量与布局,直到最内层组件本身。LookaheadLayout 内部的子组件以及所有后代组件都会经历两轮测量与布局,预测量布局之后的正式测量布局才会决定每个组件的尺寸与位置。
LookaheadLayout() 进行两轮测量布局时所使用的 LayoutModifier 有所不同。LookaheadLayout() 的子组件都可以设置一个 intermediateLayout(),它在预测量过程中会被跳过,但在正式测量过程中会被正常测量,这样它就可以拿到在 Modifier 链上位于它右侧的那个 LayoutModifier 的测量结果。比如说:
LookaheadLayout(Text("Jetpack",Modifier.layout { measurable, constraints -> } // LayoutModifier1.intermediateLayout { measurable, constraints, lookaheadSize -> } // 在 LookaheadLayout 内设置才有效.layout { measurable, constraints -> } // LayoutModifier2)
) {}
预测量时,会依次对 LayoutModifier1、LayoutModifier2 进行测量,而正式测量时,才会按照 LayoutModifier1、intermediateLayout、LayoutModifier2 的顺序测量,这样 intermediateLayout() 可以拿到 LayoutModifier2 的前瞻测量数据,也就是它的大括号内的 lookaheadSize 参数。
3.2 简单的过渡动画
Compose 提供 LookaheadLayout 的目的不在于它可以进行二次测量,而在于通过二次测量可以实现过渡动画。下面我们通过示例一步步引出 LookaheadLayout 的精准使用场景。
首先,我们想实现点击 Text 后以动画方式改变 Text 高度的功能,原始高度预设为 50dp,点击后扩展高度到 100dp:
@Composable
fun LookaheadLayoutSample() {var textHeight by remember { mutableStateOf(50.dp) }val textHeightAnim by animateDpAsState(textHeight)Column {Text("Jetpack",Modifier.height(textHeightAnim).clickable { textHeight = if (textHeight == 50.dp) 100.dp else 50.dp }.background(Color.Red) // 为了演示效果更清晰加了红色背景)Text("Compose")}
}
效果如下:
现在把需求改进一下,改为 Text 在自身高度与 100dp 之间切换。
将上一个例子中原始状态的 50dp 改为原始高度,就不能像上面那样实现了。联想一下前面所学的知识,通过 Modifier 的 onSizeChanged() 是可以获取到 Modifier 链上位于它右侧的 LayoutModifier 的尺寸变化的。但问题是,点击组件时指定 textHeight 目标的值虽然可以触发 onSizeChanged(),在里面接收 textHeight 由旧值变化到目标值过程中的中间值,但是 textHeight 的变化会引发使用了它的动画值 textHeightAnim 也变化从而改变组件的高度,这样又会触发 onSizeChanged() 形成对 textHeight 的循环递归修改,导致动画看起来非常的奇怪,而且很浪费性能(这一部分存疑,我觉得光头就是在瞎胡诌,因为用 onSizeChanged() 根本就没法实现这个需求,不是它说的递归修改高度动画会奇怪的问题,而是一个是无法让 Text 初始值就是自身高度,再一个他自己举得例子压根也没用上动画)。
由于 Text 的自身高度只有在测量之后才能知道,所以要使用 LookaheadLayout 借助它的预测量机制实现。我们先来看一下 LookaheadLayout 的参数:
/**
* [LookaheadLayout] 会运行一个预先的测量和布局过程来确定布局。紧接着,将开始另一个测量和放置过程,
* 在这个过程中,可以根据通过 [LookaheadLayoutScope.intermediateLayout] 的预加载结果调整任何布局
* 的测量和放置。
*
* 在预加载过程中,跳过了在 [LookaheadLayoutScope.intermediateLayout] 中定义的布局调整逻辑,
* 因此在预先确定目标布局时不考虑布局的任何瞬时变形。
*
* 一旦预加载完成,将开始另一个测量和布局过程。[LookaheadLayoutScope.intermediateLayout] 可用
* 于根据传入的约束和预加载结果创建一个中间布局。这可以导致布局逐渐改变其大小和位置,朝向由预加载
* 计算的目标布局。
*
* @param content 要进行布局的子组合。
* @param modifier 要应用于布局的修饰符。
* @param measurePolicy 定义布局的测量和定位的策略。
*/
@UiComposable
@Composable
fun LookaheadLayout(content: @Composable @UiComposable LookaheadLayoutScope.() -> Unit,modifier: Modifier = Modifier,measurePolicy: MeasurePolicy
)
从注释中我们能知道,LookaheadLayout 会进行两次测量与布局过程,第一次进行预测量时会跳过 intermediateLayout(),而第二次正式测量时,会执行 intermediateLayout(),根据第一次测量和布局的结果创建中间布局,用于渐变组件的尺寸与位置,实际上就是做过渡动画。
intermediateLayout() 主要有两项工作:
- 修改高度值触发动画
- 在动画的每一帧中应用最新的高度值
对于改进后的需求,使用 LookaheadLayout 的实现如下:
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun CustomLookaheadLayout() {var isOriginTextHeight by remember { mutableStateOf(true) }var textHeightPx by remember { mutableStateOf(50) }val textHeightPxAnim by animateIntAsState(textHeightPx)LookaheadLayout({Column {Text("Jetpack",// intermediate 是中间量的意思,它就是用于提供动画过程的中间值。不写// intermediateLayout() 实现的就是一个没有动画效果的高度变化Modifier.intermediateLayout { measurable, constraints, lookaheadSize ->// 这里 textHeightPx 发生变化,会导致 textHeightPxAnim 变化而触发动画。动画过程// 中给 textHeightPx 赋值不会再次触发动画,因为动画过程中 lookaheadSize 是不变的textHeightPx = lookaheadSize.height// 测量,高度限制应为动画的实时值 textHeightPxAnimval placeable = measurable.measure(Constraints.fixed(lookaheadSize.width, textHeightPxAnim))layout(placeable.width, placeable.height) {placeable.placeRelative(0, 0)}}.then(if (isOriginTextHeight) Modifier else Modifier.height(100.dp)).background(Color.Red).clickable { isOriginTextHeight = !isOriginTextHeight })Text("Compose")}}) { measurables, constraints ->val placeables = measurables.map {it.measure(constraints)}val width = placeables.maxOf { it.width }val height = placeables.maxOf { it.height }layout(width, height) {placeables.forEach { it.placeRelative(0, 0) }}}
}
效果如下:
由于使用 LookaheadLayout 通常是为了绘制过渡动画,并不会对测量和布局的逻辑进行修改,因此可以把它们提取到一个 SimpleLookaheadLayout 中作为基础组件:
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun SimpleLookaheadLayout(content: @Composable LookaheadLayoutScope.() -> Unit) {LookaheadLayout(content) { measurables, constraints ->val placeables = measurables.map {it.measure(constraints)}val width = placeables.maxOf { it.width }val height = placeables.maxOf { it.height }layout(width, height) {placeables.forEach { it.placeRelative(0, 0) }}}
}@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun CustomLookaheadLayout() {var isOriginTextHeight by remember { mutableStateOf(true) }var textHeightPx by remember { mutableStateOf(50) }val textHeightPxAnim by animateIntAsState(textHeightPx)SimpleLookaheadLayout {Column {Text("Jetpack",Modifier.intermediateLayout { measurable, constraints, lookaheadSize ->textHeightPx = lookaheadSize.heightval placeable = measurable.measure(Constraints.fixed(lookaheadSize.width, textHeightPxAnim))layout(placeable.width, placeable.height) {placeable.placeRelative(0, 0)}}.then(if (isOriginTextHeight) Modifier else Modifier.height(100.dp)).background(Color.Red).clickable { isOriginTextHeight = !isOriginTextHeight })Text("Compose")}}
}
总结起来就是,LookaheadLayout 可以预先知道自己最终的尺寸与位置,然后以动画形式渐变目标的尺寸与位置。intermediateLayout() 负责动画的初始值到最终值的中间值的绘制过程。
再考虑一下性能问题。LookaheadLayout 确实会受到二次测量的影响,但是大多数时候只会走一次测量与布局流程,而不是两次。因为 Lookahead 过程中的 constraints 如果是不变的,就会跳过该过程使用上一次的结果。比如由于尺寸变化导致 constraints 变化而触发动画,那么动画的第一帧会走 Lookahead 流程与正式测量流程,从第二帧开始,由于 constraints 与第一帧中的 constraints 一样,没有发生变化,因此 Lookahead 过程会被跳过,只执行正式的测量与布局流程。
3.3 共享元素的过渡动画
共享元素的过渡动画与同一组件中的过渡动画相比,需要多确认两点:
- 如何确定共享元素在新组件中是哪一个元素
- 如何确定共享元素在新组件中的尺寸与位置
我们先来看第二个问题。
确定共享元素在新组件中的尺寸与位置
intermediateLayout() 中的 lookaheadSize 参数只是共享元素的尺寸而不包含位置,那如何获取组件的位置信息呢?前面我们讲过一个通用的 Modifier.onPlaced():
@Stable
fun Modifier.onPlaced(onPlaced: (LayoutCoordinates) -> Unit
) = this.then(OnPlacedModifierImpl(callback = onPlaced,inspectorInfo = debugInspectorInfo {name = "onPlaced"properties["onPlaced"] = onPlaced})
)
可以通过回调参数 onPlaced 上的 LayoutCoordinates 获取到它右侧的 LayoutModifier 的位置和尺寸信息,比如 size 可以获取尺寸。同时它还有很多扩展函数,比如 positionInParent() 可以获取到一个组件在它的父组件中的相对位置。但它这个信息是正式测量的信息,而不是 Lookahead 过程的信息,因此不能用它。但是 Compose 在 LookaheadLayoutScope 接口内也提供了一个 onPlaced(),它的参数会额外提供预测量阶段的信息:
interface LookaheadLayoutScope {fun Modifier.onPlaced(onPlaced: (// LookaheadLayout 的坐标信息lookaheadScopeCoordinates: LookaheadLayoutCoordinates,// onPlaced() 右侧的 LayoutModifier 的坐标信息layoutCoordinates: LookaheadLayoutCoordinates) -> Unit): Modifier
}
onPlaced() 上的两个参数都是 LookaheadLayoutCoordinates 类型的,该类型是 LayoutCoordinates 的子接口:
sealed interface LookaheadLayoutCoordinates : LayoutCoordinates {fun localLookaheadPositionOf(sourceCoordinates: LookaheadLayoutCoordinates,relativeToSource: Offset = Offset.Zero): Offset
}
子接口内多提供的一个函数 localLookaheadPositionOf() 用于扩展 LayoutCoordinates 内的 localPositionOf():
/*** Converts an [relativeToSource] in [sourceCoordinates] space into local coordinates.* [sourceCoordinates] may be any [LayoutCoordinates] that belong to the same* compose layout hierarchy.*/fun localPositionOf(sourceCoordinates: LayoutCoordinates, relativeToSource: Offset): Offset
localPositionOf() 是坐标转换的工具函数,用于计算 sourceCoordinates 坐标系中的 relativeToSource 点在本地坐标系中的坐标。而 localLookaheadPositionOf() 也提供了类似的功能,只不过它提供的是前瞻阶段的坐标转换。常用写法:
SimpleLookaheadLayout {Text("Jetpack",Modifier.onPlaced { lookaheadScopeCoordinates, layoutCoordinates ->// 前瞻阶段相对位移,用于计算过渡动画val offset0 =lookaheadScopeCoordinates.localLookaheadPositionOf(layoutCoordinates)// 正式阶段相对位移val offset1 = lookaheadScopeCoordinates.localPositionOf(layoutCoordinates,Offset.Zero)})
}
intermediateLayout() 在计算尺寸时是既负责初始化又负责计算动画中间值,而在计算位置时,它只负责计算中间值,初始值的任务交给 onPlaced()。
讲课时秃头是先讲的在同一个组件内的位置偏移,通过 padding() 改变了 Text 组件的位置(没用 offset() 是因为可能会超出父组件,不方便举例)。
写法如下:
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun CustomLookaheadLayout1() {var isOriginTextHeight by remember { mutableStateOf(true) }var textHeightPx by remember { mutableStateOf(50) }val textHeightPxAnim by animateIntAsState(textHeightPx)var lookaheadOffset by remember { mutableStateOf(Offset.Zero) }val lookaheadOffsetAnim by animateOffsetAsState(lookaheadOffset)SimpleLookaheadLayout {Column {Text("Jetpack",Modifier// 改变位置的 Modifier 应该写在 LayoutModifier 的左侧,改变大小的写在右侧.then(if (isOriginTextHeight) Modifier else Modifier.padding(50.dp)).onPlaced { lookaheadScopeCoordinates, layoutCoordinates ->// 前瞻阶段相对位移lookaheadOffset =lookaheadScopeCoordinates.localLookaheadPositionOf(layoutCoordinates)}.intermediateLayout { measurable, constraints, lookaheadSize ->textHeightPx = lookaheadSize.heightval placeable = measurable.measure(Constraints.fixed(lookaheadSize.width, textHeightPxAnim))layout(placeable.width, placeable.height) {// 内部组件相对于 intermediateLayout 的额外偏移,而不是在父组件中的坐标值,// lookaheadOffsetAnim.x 是基于原点的偏移placeable.placeRelative((lookaheadOffsetAnim - lookaheadOffset).x.roundToInt(),(lookaheadOffsetAnim - lookaheadOffset).y.roundToInt())}}.then(if (isOriginTextHeight) Modifier else Modifier.height(50.dp)).background(Color.Red).clickable { isOriginTextHeight = !isOriginTextHeight })}}
}
效果如下:
共享元素的识别
Compose 提供了 movableContentOf() 将共享元素放入其中即可:
val sharedText = movableContentOf { Text("Jetpack") }
现在把 Text 放入 movableContentOf() 中会报错,因为把 Text 提到 movableContentOf() 里面之后,没有了 LookaheadLayoutScope 的环境致使一些函数无法调用,因为因此使用 movableContentWithReceiverOf() 在泛型中指定 LookaheadLayoutScope,相当于提供了 LookaheadLayoutScope 的调用环境:
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun CustomLookaheadLayout1() {var isOriginTextHeight by remember { mutableStateOf(true) }var textHeightPx by remember { mutableStateOf(50) }val textHeightPxAnim by animateIntAsState(textHeightPx)var lookaheadOffset by remember { mutableStateOf(Offset.Zero) }val lookaheadOffsetAnim by animateOffsetAsState(lookaheadOffset)// 假设有这个环境val sharedText = movableContentWithReceiverOf<LookaheadLayoutScope> {Text("Jetpack",Modifier// 改变位置的 Modifier 应该写在 LayoutModifier 的左侧,改变大小的写在右侧.then(if (isOriginTextHeight) Modifier else Modifier.padding(50.dp)).onPlaced { lookaheadScopeCoordinates, layoutCoordinates ->// 前瞻阶段相对位移lookaheadOffset =lookaheadScopeCoordinates.localLookaheadPositionOf(layoutCoordinates)}.intermediateLayout { measurable, constraints, lookaheadSize ->textHeightPx = lookaheadSize.heightval placeable = measurable.measure(Constraints.fixed(lookaheadSize.width, textHeightPxAnim))layout(placeable.width, placeable.height) {// 内部组件相对于 intermediateLayout 的额外偏移,而不是在父组件中的坐标值,// lookaheadOffsetAnim.x 是基于原点的偏移placeable.placeRelative((lookaheadOffsetAnim - lookaheadOffset).x.roundToInt(),(lookaheadOffsetAnim - lookaheadOffset).y.roundToInt())}}.then(if (isOriginTextHeight) Modifier else Modifier.height(50.dp)).background(Color.Red).clickable { isOriginTextHeight = !isOriginTextHeight })}SimpleLookaheadLayout {// 通过条件模拟切换两个页面if (isOriginTextHeight) {sharedText()} else {Column {sharedText()Text("Compose")}}}
}
效果: