相关文章:
Compose编程思想 -- Compose中的经典Modifier(LayoutModifier)
前言
在传统的自定义View中,通常是通过画笔Paint
直接在Canvas
上画想要的内容,例如图片、文字、线条等。
kotlin
class CircleView @JvmOverloads constructor(
context: Context,
attributeSet: AttributeSet? = null,
flag: Int = 0
) : View(context, attributeSet, flag) {
private var mPaint: Paint = Paint()
init {
mPaint.color = Color.RED
mPaint.strokeWidth = 20f
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
canvas.drawCircle(width / 2f, height / 2f, width / 2f, mPaint)
}
}
那么从这一节开始,我将会介绍Compose中自定义View是如何完成绘制的。
1 Compose自定义绘制流程
本节将会介绍Compose自定义绘制中常用的API,因为前面的课程中(可以查看文章开头的相关文章)详细介绍了DrawModifier的原理,因此在这节中我会默认你已经熟练掌握了DrawModifier原理性的问题。
1.1 drawBehind
先看个具体的场景,例如我有一个Text
组件,我想给这个Text
加一个圆角矩形的背景,类似于一个按钮,这也是在实际的项目中,UI小姐姐经常会给出的需求,因为material design类型的按钮并不能满足需求。
kotlin
@Composable
fun CustomDrawView() {
Box(Modifier
.drawBehind {
drawRoundRect(Color.Blue, cornerRadius = CornerRadius(20.dp.toPx(), 20.dp.toPx()))
}
.width(90.dp)
.height(40.dp)) {
Text(
text = "自定义绘制",
Modifier.align(Alignment.Center),
color = Color.White,
fontWeight = FontWeight(400)
)
}
}
这里是使用了drawBehind
来给Box
加了一个背景,它在底层的实现就是就是一个ModifierNodeElement
,当进行Node节点初始化的时候,会创建DrawBackgroundModifier
。
kotlin
fun Modifier.drawBehind(
onDraw: DrawScope.() -> Unit
) = this then DrawBehindElement(onDraw)
@OptIn(ExperimentalComposeUiApi::class)
private data class DrawBehindElement(
val onDraw: DrawScope.() -> Unit
) : ModifierNodeElement<DrawBackgroundModifier>() {
override fun create() = DrawBackgroundModifier(onDraw)
override fun update(node: DrawBackgroundModifier) = node.apply {
onDraw = this@DrawBehindElement.onDraw
}
override fun InspectorInfo.inspectableProperties() {
name = "drawBehind"
properties["onDraw"] = onDraw
}
}
DrawBackgroundModifier
是一个DrawModifierNode
,当系统执行绘制的时候,会执行其draw
函数,我们可以看到drawBehind
提供的作用域内部实现先执行,再执行drawContent
。
kotlin
@OptIn(ExperimentalComposeUiApi::class)
private class DrawBackgroundModifier(
var onDraw: DrawScope.() -> Unit
) : Modifier.Node(), DrawModifierNode {
override fun ContentDrawScope.draw() {
onDraw()
drawContent()
}
}
所以drawBehind
就是用于在原始组件的基础上,给其背景添加元素,其实就是实现了Modifier.background
的作用。
但是drawBehind
只能在原有组件的底部绘制内容,如果想要绘制一些覆盖组件的内容,那么就需要使用drawWithContent
来实现。
kotlin
Text(
text = "自定义绘制",
Modifier
.align(Alignment.Center)
.drawWithContent {
drawContent()
drawLine(
Color.Red,
Offset(0f, size.height / 2),
Offset(size.width, size.height / 2),
strokeWidth = 5.dp.toPx()
)
},
color = Color.White,
fontWeight = FontWeight(400)
)
这就需要对Text
做一下处理,添加一个drawLine
的操作。
1.2 Canvas自定义组件绘制
如果当前的需求需要我在一个空白的页面上绘制自定义的内容,其实有两种方案:
- 方案1:在
Box
内部绘制内容
kotlin
@Composable
fun CanvasDraw() {
Box(modifier = Modifier.drawBehind {
drawCircle(Color.Red)
}.size(50.dp))
}
- 方案2:使用
Canvas
绘制内容
在Compose中,提供了在空白区域绘制自定义内容的组件Canvas
,通过源码可以看到是在Spacer
组件中画,如果是从头看我文章的伙伴,应该知道Spacer
的用处,就是用来设置Margin的,也就是一个无内容的间距。
kotlin
@Composable
fun Canvas(modifier: Modifier, onDraw: DrawScope.() -> Unit) =
Spacer(modifier.drawBehind(onDraw))
在Spacer
中也是执行了drawBehind
函数,在其背景上画内容,其实与Box
类似。
kotlin
@Composable
fun CustomDraw(
modifier: Modifier = Modifier,
resId: Int
) {
val imageBitmap = ImageBitmap.imageResource(id = resId)
Canvas(modifier = modifier.size(100.dp), onDraw = {
drawImage(imageBitmap)
})
}
提到Canvas,很容易联想到Android原生的Canvas,也就是画布,在使用软件加速的场景中,CPU将会在计算过后将图像绘制到画布上。而上面提到Compose中的Canvas,与原生的Canvas不是一回事,如果想要使用原生的Canvas能力,Compose则是提供了drawIntoCanvas
函数,用于获取原生的Canvas。
kotlin
/**
* Provides access to draw directly with the underlying [Canvas]. This is helpful for situations
* to re-use alternative drawing logic in combination with [DrawScope]
*
* @param block Lambda callback to issue drawing commands on the provided [Canvas]
*/
inline fun DrawScope.drawIntoCanvas(block: (Canvas) -> Unit) = block(drawContext.canvas)
在原生Canvas的使用中,会使用到Paint
,通过画布来定义绘制的内容颜色、线条的宽度等等,而且一些原生的Canvas API同样也可以使用到。
kotlin
@Composable
fun CustomDraw() {
//需要一个画笔
val paint by remember {
mutableStateOf(Paint().apply {
color = Color.Red
strokeWidth = 5f
})
}
Canvas(modifier = Modifier.size(100.dp), onDraw = {
drawIntoCanvas {nativeCanvas->
with(nativeCanvas){
drawLine(
Offset(0f,size.height/2),
Offset(size.width,size.height/2),
paint = paint
)
}
}
})
}
所以提供原生Canvas的原因就是,当Compose中的Canvas无法完成一些需求定制时,需要使用原生的Canvas才能做到,那么可以使用drawIntoCanvas
。
2 Compose自定义布局流程
在Android原生的View体系中,谈到自定义布局,80%的需求都可以通过继承自LinearLayout
、ConstraintLayout
等布局来完成,只有当现有的布局不能满足需求,例如设计一个流式布局,那么此时就需要继承自ViewGroup
来实现onMeasure
和onLayout
,来测量子view的宽高来确定自身的宽高,同时在onLayout
中摆放view的位置。
那么在Compose中,谈到自定义布局主要为两种:Layout
和SubcomposeLayout
。
2.1 Layout
前面我在讲Modifier.layout
的时候提到过,LayoutModifier仅仅能决定某个组件的大小和位置,如果想要控制子组件的位置和大小,那么就得使用Layout。
点开所有Compose的组件,基本上都会见到Layout
这个函数,例如Column
组件,就是通过Layout
完成子view的摆放逻辑。
kotlin
@Composable
inline fun Column(
modifier: Modifier = Modifier,
verticalArrangement: Arrangement.Vertical = Arrangement.Top,
horizontalAlignment: Alignment.Horizontal = Alignment.Start,
content: @Composable ColumnScope.() -> Unit
) {
val measurePolicy = columnMeasurePolicy(verticalArrangement, horizontalAlignment)
Layout(
content = { ColumnScopeInstance.content() },
measurePolicy = measurePolicy,
modifier = modifier
)
}
2.1.1 Layout源码分析
看下Layout
源码,在Compose当中,提供了带有content
参数以及不带content
参数的Layout,两者的区别在于,content
一般指布局中的子组件,而不带content
一般指没有子组件的自定义View,这种情况下,其实跟Modifier.layout
类似。
kotlin
@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
)
}
不带content
参数的Layout
源码:
kotlin
@Composable
@UiComposable
inline fun Layout(
modifier: Modifier = Modifier,
measurePolicy: MeasurePolicy
) {
val density = LocalDensity.current
val layoutDirection = LocalLayoutDirection.current
val viewConfiguration = LocalViewConfiguration.current
val materialized = currentComposer.materialize(modifier)
ReusableComposeNode<ComposeUiNode, Applier<Any>>(
factory = ComposeUiNode.Constructor,
update = {
set(measurePolicy, ComposeUiNode.SetMeasurePolicy)
set(density, ComposeUiNode.SetDensity)
set(layoutDirection, ComposeUiNode.SetLayoutDirection)
set(viewConfiguration, ComposeUiNode.SetViewConfiguration)
set(materialized, ComposeUiNode.SetModifier)
},
)
}
所以我将会重点介绍带content
参数的用法。除了content
参数之外,还有一个MeasurePolicy
对象,看下这个是啥?
kotlin
fun interface MeasurePolicy {
fun MeasureScope.measure(
measurables: List<Measurable>,
constraints: Constraints
): MeasureResult
// ......
}
看下核心函数,也有一个measure
函数,但是跟Modifier.layout
不同的是,Layout
拥有全部子组件的测量结果measurables
,它是一个集合,因此可以对每一个子组件测量。
2.2.2 自定义Layout流程
假设,现在要做一个水平线性布局,也就是Row
,其实相较于原生的自定义ViewGroup,Compose中的自定义布局很简单,分3步走:
(1)测量子组件,使用Measureable.measure
来获取子组件的尺寸,拿到每个子组件的Placeable
,以及组件的最大宽高。
(2)给父容器设置宽高,layout(width,height)
,就是传统自定义View中的setMeasureDimension
。
(3)在布局摆放的时候,通过测量之后拿到的List<Placeable>
,根据这个布局实现的功能,自定义逻辑,对每个Placeable
进行设置。
下面的源码是自己手写的简单Row
:
kotlin
@Composable
fun CustomRow(
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
Layout(content, modifier) { measureables, constraints ->
//测量子组件宽高,定义父容器的宽高
var width = 0
var height = 0
//测量全部子组件的宽高
val childPlaceables = measureables.map { measurable ->
val placeable = measurable.measure(constraints)
width += placeable.width
height = max(height, placeable.height)
placeable
}
//开始摆放
layout(width, height) {
//每个子组件摆放位置,需要子组件的宽高决定
var currentX = 0
childPlaceables.forEach { placeable ->
placeable.placeRelative(currentX, 0)
currentX += placeable.width
}
}
}
}
使用效果展示:
kotlin
@Preview
@Composable
fun TestCustomRow() {
CustomRow {
Text(text = "组件1", Modifier.background(Color.Blue))
Text(text = "组件2", Modifier.background(Color.Yellow))
Text(text = "组件3", Modifier.background(Color.Red))
}
}
2.2 SubcomposeLayout
SubcomposeLayout
是一种比Layout
更高级的布局方式,像滑动列表LazyColumn
中就使用到了SubcomposeLayout
。
SubcomposeLayout
以Sub
开头,在传统的View窗口体系中,像Dialog属于子窗口,或者是Sub Window,它必须要依附于Activity这些应用程序窗口上,属于应用程序窗口的一部分。所以SubcomposeLayout
也可以这么理解,它属于组合中的一部分,但是不会跟着Compose组合流程执行,可能会推迟到测量和布局的流程。
2.2.1 Compose渲染流程
首先回顾一下Compose的渲染流程:
- composition,可以翻译为组合阶段。这个阶段会执行所有的组合函数,将其转换为Compose界面对象,也就是
LayoutNode
对象。 以及初始化操作LayoutNode
节点的NodeCoordinator
。 - measure/layout,这个阶段就是测量和布局阶段,这个也是传统View体系中不可缺少的一环,具体的测量和布局的流程,可以看一下
LayoutModifier
那一节课程。 - draw,绘制阶段,在这个阶段中会将
DrawModifier
定义的内容绘制到Canvas上,展示给用户。
所以正常的流程中,所有的组合函数在第一时间被调用,而SubcomposeLayout
则是将组合过程延迟到了测量或者布局的阶段。
2.2.2 SubcomposeLayout的作用
那么SubcomposeLayout
有什么用呢?其实大部分界面在组合阶段就已经定好了,而有些界面则是需要根据屏幕尺寸,不同的机型做适配,从而展示不同的布局。然而其他的组件在测量布局阶段是无法进行组合操作的,因此通过SubcomposeLayout
就可以在测量阶段完成组合操作,实现布局的动态加载。
kotlin
@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) }
}
}
在Compose中提供了可以在测量阶段组合的Box
容器,就是BoxWithConstraints
,在其提供的BoxWithConstraintsScope
作用域内,可以拿到容器的最大宽高,以便处理布局加载的逻辑。
kotlin
@Composable
fun TestSubcomposeLayout(){
BoxWithConstraints(
modifier = Modifier.fillMaxWidth()
) {
if (constraints.maxWidth > 200.dp.toPx()){
Text(text = "大布局")
}else{
Text(text = "小布局")
}
}
}
2.2.3 SubcomposeLayout的使用
接下来介绍SubcomposeLayout
的使用方式,首先看下源码,measurePolicy
参数是SubcomposeMeasureScope
提供的作用域,其提供了一个Constraints
参数,是父容器给子组件的建议值,类似于传统View体系中,onMeasure方法提供的两个MeasureSpec参数。
kotlin
@Composable
fun SubcomposeLayout(
modifier: Modifier = Modifier,
measurePolicy: SubcomposeMeasureScope.(Constraints) -> MeasureResult
) {
SubcomposeLayout(
state = remember { SubcomposeLayoutState() },
modifier = modifier,
measurePolicy = measurePolicy
)
}
SubcomposeMeasureScope
是继承自MeasureScope
,意味着可以调用layout
布局,同时提供了subcompose
函数,用于在测量阶段进行组合composition 。返回值是Measurable
数组,因为子组件可能存在多个。
kotlin
interface SubcomposeMeasureScope : MeasureScope {
/**
* Performs subcomposition of the provided [content] with given [slotId].
*/
fun subcompose(slotId: Any?, content: @Composable () -> Unit): List<Measurable>
}
所以在使用SubcomposeLayout
时,需要调用subcompose
来获取到所有子组件的尺寸,后续的处理和Layout
如出一辙,具体逻辑可以根据业务场景来定义。
kotlin
@Composable
fun CustomSubcomposeLayout(
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
SubcomposeLayout { constraints ->
// constraints 父容器给子组件的建议尺寸
val measurables = subcompose(Unit) {
content()
}
measurables.map {
}
layout(0,0){
}
}
}
当然这是整体的处理,SubcomposeLayout
还可以根据前一个组件的尺寸大小,来决定下一个组件显示什么。
kotlin
SubcomposeLayout { constraints ->
// constraints 父容器给子组件的建议尺寸
val measurable = subcompose(Unit) {
Text("第一个组件")
}
val placeable = measurable.getOrNull(0)?.measure(constraints)
subcompose(Unit) {
placeable?.let {
if (it.width > 20) {
Image(
painter = painterResource(id = R.drawable.ic_launcher_background),
contentDescription = null
)
} else {
Text("第二个组件")
}
}
}
layout(0, 0) {
}
}
也就是说,subcompose
可以被执行多次,以便处理复杂的业务场景。但是使用的时候要谨慎使用,因为Compose在做重组优化的时候,比如一些测量绘制的场景本不需要重组,但是SubcomposeLayout
在测量绘制的过程中会进行组合流程的处理,如果在频繁测量绘制的场景中,会发生频繁的重组,那么必然会带来性能问题,严重时会导致卡顿。