Compose:自定义布局

Layout()

当我们讲到原生的自定义布局,一般是指的自己实现一个 View 或者 ViewGroup 的子类,可能会重写 onMeasure() 和 onLayout() 达到我们想要的效果。

但是在 Compose 所有的组件都是用纯 kotlin 代码,在定义自定义布局时,如果是单纯的摆放 Composable,可以认为是不算的:

setContent {
	// 在 Compose 这不能算自定义布局,因为只是简单的组件组合
	Row {
		Text()
		Image()
	}
}

Compose 能通过 LayoutModifier 定制一个组件的测量布局效果:

setContent {
	Box(Modifier.size(80.dp).layout { measurable, constraints -> 
		// 自定义测量布局...
	})
}

如果要自定义可以管控子组件的 Composable,Compose 提供了一个 Layout() 的 Composable,可以通过它定义一套自己的测量布局算法实现想要的效果

setContent {
	// measurables 是一个 list,可以管理子组件的测量布局
	Layout { measurables, constraints -> }
}

尝试下用 Layout() 实现一个自定义的布局效果:

@Preview
@Composable
fun CustomLayoutPreivew() {
    // 从上到下摆放效果
    CustomLayout {
        Box(Modifier.size(80.dp).background(Color.Red))
        Box(Modifier.size(80.dp).background(Color.Yellow))
        Box(Modifier.size(80.dp).background(Color.Blue))
    }
}

// content 参数暴露给调用者提供子组件
@Composable
fun CustomLayout(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
    Layout(content, modifier) { measurables, constraints ->
        var width = 0
        var height = 0
        // 遍历所有子组件
        val placeables = measurables.map { measurable ->
            measurable.measure(constraints).also { placeable ->
                // 记录最大的组件宽度
                width = max(width, placeable.width)
                // 累加所有组件的高度
                height += placeable.height
            }
        }
        layout(width, height) {
            var totalHeight = 0
            placeables.forEach {
            	// 从上往下摆放子组件
                it.placeRelative(0, totalHeight)
                totalHeight += it.height
            }
        }
    }
}

在这里插入图片描述

SubcomposeLayout()

Subcompose 的意思是次级的组合,在 Compose 是指的它不跟着整体的组合流程走而是独立的,但从结构上又归属于整体的组合。

Compose 的流程是分为组合、测量、布局、绘制四个步骤,SubcomposeLayout 能做到将一部分的组合过程延后到测量阶段或布局阶段再进行,这样能实现拿到需要测量或布局完成才能拿到的数据后再组合的业务需求,比如用于实现动态布局。

setContent {
	// BoxWithConstraints 是用 SubcomposeLayout 实现的,可以当成 Box() 使用
	// 也是使用 SubcomposeLayout 的简化组件,比如只需要拿到测量尺寸限制
	
	// 在 BoxWithConstraints 可以获取到 constraints 变量,它是父组件提供的尺寸限制
	// constraints 是在测量阶段才能拿到的东西,组合阶段要早于测量阶段
	// 所以如果使用 Box()是拿不到该变量的,因为 Box() 还处在组合过程,还没到测量阶段
	BoxWithConstraints() {
		// BoxWithConstraints() 的 lambda 发生在测量阶段
		Text("test", Modifier.align(Alignment.Center))
	}
}

动态布局的场景很常见,比如我们在原生 View 系统开发需要针对不同的屏幕比如横屏或竖屏提供两套同名但布局不同的 xml 文件,会定义在不同的 layout 目录下分别加载;因为 Compose 是纯代码开发的,所以这类场景可以根据测量阶段的尺寸限制判断要使用哪种布局。

setContent {
	// 根据不同尺寸加载不同的布局
	BoxWithConstraints {
		// minWidth 调用的是 constraints.minWidth
		if (minWidth >= 360.dp) {
			LandscapeLayout()
		} else {
			PortraitLayout()
		}
	}
}

SubcomposeLayout 更多的会用在比如针对某个组件测量完成后,再根据这个组件再测量摆放的位置等

setContent {
	// SubcomposeLayout 需要自己调用测量布局,是为了能更好的让我们调整测量布局时机
	// 如果只需要测量尺寸限制使用 BoxWithConstraints() 即可
	SubcomposeLayout { constraints ->
		// 测量阶段
		// subcompose(slotId) 提供的参数 slotId 用于判断是否是同一个,主要用于优化性能
		val measurable1 = subcompose(1) {
			Xxx()
		}[0]
		val placeable1 = measurable1.measure(constriants)
		
		// 根据第一个 Composable 确认下一个使用的什么组件或其他处理
		val measurable2 = if (placeable1.width > 1000) subcompose(2) {
			Xxx()	
		} else {
			Xxx()
		}
		val placeable2 = measurable2.measure(constraints)
	
		// ....
		
		// 布局阶段
		layout(placeable.width, placeable.height) {
			placeable.placeRelative(0, 0)
		}
	}
}

SubcomposeLayout 虽然很强大,Compose 在组合阶段是做了很多优化工作的,但是 SubcomposeLayout 因为独立于整体组合流程在结构上又属于整体流程上,整体流程的重新测量布局将会导致 SubcomposeLayout 内部重新执行重组,对界面的性能有负面影响

比如 LazyColumn() 就是使用的 SubcomposeLayout 实现的,在某些场景下确实会出现界面性能影响,比如对 LazyColumn() 整体宽高做缩放动画的时候,会触发频繁的重复测量,也就是 SubcomposeLayout 会频繁的触发重组。其实对于这类动态组件做该操作都会存在界面性能损耗卡顿的情况,即使是 RecyclerView 也一样,但并不代表它就不能使用。

所以在日常开发中,如果有通用方案能实现相同的业务需求,能不加入 SubcomposeLayout 的流程就不加入,也不会太高频的使用到它,不过有需要的业务需求我们可以使用它

LookaheadLayout()

LookaheadLayout() 的作用是在实际测量之前能预先拿到测量结果,根据这个测量结果再进行正式的测量。但需要注意的是,它并不是二次测量。它被设计出来的目的是用于做过渡动画

Compose 对二次测量的禁止是在布局里面对子组件进行二次测量,对于单个组件的布局比如在 Modifier.layout() 测量是不限制的

setContent {
	Layout({
		Text("test", Modifier.layout { measurable, constraints ->
			measurable.measure(constraints) // 单个组件布局测试二次测量,正常运行
			val placeable = measurable.measure(constraints)	
			layout(placeable.width, placeable.height) {
				placeable.placeRelative(0, 0)
			}
		})
	}) { measurables, constraints ->
		// java.lang.IllegalStateException: measure() may not be called multiple times on the same Measurable. Current state InMeasureBlock. Parent state Measuring.
		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) {
			placeables.forEach { it.placeRelative(0, 0) }
		}
	}
}

在一开始我们提到,LookaheadLayout() 并不是二次测量,而是有两次测量,一次是 Lookahead 即前瞻性测量,第二次才是正式测量

LookaheadLayout() 的使用一般需要配合上 Modifier.intermediateLayout(),它也是属于 LayoutModifier:

@Composable
private fun CustomLookaheadLayout() {
	LookaheadLayout({
		Column {
			Text("test1", Modifier
				.layout { measurable, constraints -> }
				.intermediateLayout { measurable, constraints, lookaheadSize -> 
					// lookaheadSize 就是下面第一次前瞻性测量的 LayoutModifier 的测量信息
					// 可以在第二次正式测量时拿着 lookaheadSize 调整测量结果
				}
				.layout { measurable, constraints -> })
			Text("test2")
		}
	}) { 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) }
		}		
	}
}

为了方便演示什么是前瞻性测量,在 Text() 组件多设置了两个 LayoutModifier,这里假设第一个 LayoutModifier 命名为 Layout1,第二个 LayoutModifier 命名为 Layout2;还有一个配合 LookaheadLayout() 使用的 IntermediateLayout。

  • 第一次测量的时候,组件如果添加了 IntermediateLayout,有关 IntermediateLayout 的测量过程将会被跳过,即只执行了两个 LayoutModifier 的处理过程。这次是属于前瞻性测量

  • 第二次测量是正式测量,此时两个 LayoutModifier 包括 IntermediateLayout 都会被执行

根据上面的分析,IntermediateLayout 能够很直观的拿到 Layout2 的测量结果即 lookaheadSize 参数,然后再正式测量时拿着 Layout2 的测量结果做处理。

@Composable
private fun CustomLookaheadLayout() {
	LookaheadLayout({
		Column {
			Text("test1", Modifier
				.background(Color.Yellow)
				.intermediateLayout { measurable, constraints, lookaheadSize -> 
					// 调整为两倍高度
					val placeable = measurable.measure(
						Constraints.fixed(lookaheadSize.width, lookaheadSize.height * 2))
					layout(placeable.width, placeable.height) {
						placeable.placeRelative(0, 0)
					}
				})
			Text("test2")
		}
	}) { 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) }
		}		
	}
}

// 效果图

如果是上面的效果,其实使用 LayoutModifier 同样也可以实现:

@Composable
private fun CustomLookaheadLayout() {
	Column {
		Text("test1", Modifier
			.layout { measurable, constraints -> 
				var placeable = measurable.measure(constraints)
				placeable = measurable.measure(
					Constraints.fixed(placeable.width, placeable.height * 2))
				layout(placeable.width, placeable.height) {
					placeable.placeRelative(0, 0)
				}
			}
		)
		Text("test2")	
	}
}

既然效果是一样的,那么为什么还需要提供一个 LookaheadLayout 和 IntermediateLayout?

Compose 团队提供 LookaheadLayout 的目的并不是为了用于二次测量,而是用于做过渡动画的效果。

实现过渡动画需要有三个要素:

  • 尺寸调整

  • 位置偏移调整

  • 共享元素

按上面的三个要素分析下 LookaheadLayout 是怎么实现的过渡动画。

LookaheadLayout 调整组件尺寸

假设现在我们想要实现点击一个组件,组件的高度动态增加到指定的高度,比如 100dp 到 200dp ,并且要求使用动画的方式:

@Composable
private fun CustomLookaheadLayout() {
    var textHeight by remember { mutableStateOf(100.dp) }
    val textHeightAnim by animateDpAsState(textHeight)
    Column {
        Text("test1",
            Modifier
                .background(Color.Yellow)
                .height(textHeightAnim)
                .clickable {
                    textHeight = if (textHeight == 100.dp) 200.dp else 100.dp
                })
        Text("test2")
    }
}

在这里插入图片描述

基于上面的效果,我们再调整下需求:现在需要在组件自身高度的基础上调整到 200dp。因为是要在自身高度的基础上调整,所以思路是要 先进行测量,测量出来结果后再做动画调整,这时候就需要用到 LookaheadLayout 和 IntermediateLayout

@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun CustomLookaheadLayout() {
    var isTextHeight200Dp by remember { mutableStateOf(false) }
    var textHeightPx by remember { mutableStateOf(0) }
    val textHeightPxAnim by animateIntAsState(textHeightPx)
    Column {
        SimpleLookaheadLayout {
            Text("test1", Modifier
                .background(Color.Yellow)
                // intermediateLayout 提供了从初始到动画目标数值变化的获取即 lookaheadSize
                .intermediateLayout { measurable, constraints, lookaheadSize ->
                    // textHeightPx 被修改,会重新触发动画启动
                    textHeightPx = lookaheadSize.height
                    // intermediateLayout 被重复调用,刷新动画每一帧
                    val placeable = measurable.measure(
                        Constraints.fixed(lookaheadSize.width, textHeightPxAnim)
                    )
                    layout(placeable.width, placeable.height) {
                        placeable.placeRelative(0, 0)
                    }
                }
                .then(if (isTextHeight200Dp) Modifier.height(200.dp) else Modifier)
                .clickable {
                    isTextHeight200Dp = !isTextHeight200Dp
                })
        }
        Text("test2")
    }
}

// 如果使用 LookaheadLayout 布局流程不做任何处理,但又不想写模板代码,就抽离出来
@OptIn(ExperimentalComposeUiApi::class)
@Composable
private 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) }
        }
    }
}

在这里插入图片描述

LookaheadLayout 调整位置偏移

上面是使用 LookaheadLayout 进行尺寸的调整,要实现过渡动画除了尺寸之外,还需要位置的偏移,在 LookaheadLayout 提供了双参数的 OnPlaceModifier 实现:

@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun CustomLookaheadLayout() {
	var isTextHeight200Dp by remember { mutableStateOf(false) }
	var textHeightPx by remember { mutableStateOf(0) }
	val textHeightPxAnim by animateIntAsState(textHeightPx)
	var lookaheadOffset by remember { mutableStateOf(Offset.Zero) }
	val lookaheadOffsetAnim by animateOffsetAsState(lookaheadOffset)
	SimpleLookaheadLayout {
		Column {
			Text("test1", Modifier
				.background(Color.Yellow)
				.then(if (isTextHeight200Dp) Modifier.padding(50.dp) else Modifier)
				// 调整偏移
				.onPlaced { lookaheadScopeCoordinates, layoutCoordinates -> 
					lookaheadOffset = lookaheadScopeCoordinates.localLookaheadPositionOf(layoutCoordinates)
				}
				.intermediateLayout { measurable, constraints, lookaheadSize -> 
					textHeightPx = lookaheadSize.height
					val placeable = measurable.measure(
						Constraints.fixed(lookaheadSize.width, textHeightPxAnim)
					)
					layout(placeable.width, placeable.height) {
						// 动画调整偏移位置
						placeable.placeRelative((lookaheadOffsetAnim - lookaheadOffset).x.roundToInt(), 
							(lookaheadOffsetAnim - lookaheadOffset).y.roundToInt())
					}
				}
				.then(if (isTextHeight200Dp) Modifier.height(200.dp) else Modifier)
				.clickable {
					isTextHeight200Dp = !isTextHeight200Dp
				})
			Text("test2")
		}
	}
}

在这里插入图片描述

LookaheadLayout 元素共享

知道了怎样调整尺寸和偏移位置,剩下的就是过渡动画对共享元素的识别,Compose 提供了 movableContentOf() 处理共享元素的识别:

@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun CustomLookaheadLayout() {
	var isTextHeight200Dp by remember { mutableStateOf(false) }
	var textHeightPx by remember { mutableStateOf(0) }
	val textHeightPxAnim by animateIntAsState(textHeightPx)
	var lookaheadOffset by remember { mutableStateOf(Offset.Zero) }
	val lookaheadOffsetAnim by animateOffsetAsState(lookaheadOffset)
	// 共享元素
	val sharedText = remember {
		movableContentWithReceiverOf<LookaheadLayoutScope> {
			Text("test1", Modifier
				.background(Color.Yellow)
				.then(if (isTextHeight200Dp) Modifier.padding(50.dp) else Modifier)
				.onPlaced { lookaheadScopeCoordinates, layoutCoordinates -> 
					lookaheadOffset = lookaheadScopeCoordinates.localLookaheadPositionOf(layoutCoordinates)
				}
				.intermediateLayout { measurable, constraints, lookaheadSize -> 
					textHeightPx = lookaheadSize.height
					val placeable = measurable.measure(
						Constraints.fixed(lookaheadSize.width, textHeightPxAnim)
					)
					layout(placeable.width, placeable.height) {
						placeable.placeRelative((lookaheadOffsetAnim - lookaheadOffset).x.roundToInt(), 
							(lookaheadOffsetAnim - lookaheadOffset).y.roundToInt())
					}
				}
				.then(if (isTextHeight200Dp) Modifier.height(200.dp) else Modifier)
				.clickable {
					isTextHeight200Dp = !isTextHeight200Dp
				})			
		}	
	}
	SimpleLookaheadLayout {
		// 模拟跨界面共享元素
		if (isTextHeight200Dp) {
			sharedText()
		} else {
			Column {
				Text("test2")
				sharedText()
			}
		}
	}
}

在这里插入图片描述

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值