Compose编程思想 -- Compose中的自定义View

相关文章:

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%的需求都可以通过继承自LinearLayoutConstraintLayout等布局来完成,只有当现有的布局不能满足需求,例如设计一个流式布局,那么此时就需要继承自ViewGroup来实现onMeasureonLayout,来测量子view的宽高来确定自身的宽高,同时在onLayout中摆放view的位置。

那么在Compose中,谈到自定义布局主要为两种:LayoutSubcomposeLayout

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

SubcomposeLayoutSub开头,在传统的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在测量绘制的过程中会进行组合流程的处理,如果在频繁测量绘制的场景中,会发生频繁的重组,那么必然会带来性能问题,严重时会导致卡顿。

相关推荐
姑苏风3 小时前
《Kotlin实战》-附录
android·开发语言·kotlin
数据猎手小k6 小时前
AndroidLab:一个系统化的Android代理框架,包含操作环境和可复现的基准测试,支持大型语言模型和多模态模型。
android·人工智能·机器学习·语言模型
你的小107 小时前
JavaWeb项目-----博客系统
android
风和先行8 小时前
adb 命令查看设备存储占用情况
android·adb
AaVictory.8 小时前
Android 开发 Java中 list实现 按照时间格式 yyyy-MM-dd HH:mm 顺序
android·java·list
测试19989 小时前
2024软件测试面试热点问题
自动化测试·软件测试·python·测试工具·面试·职场和发展·压力测试
似霰9 小时前
安卓智能指针sp、wp、RefBase浅析
android·c++·binder
大风起兮云飞扬丶9 小时前
Android——网络请求
android
干一行,爱一行9 小时前
android camera data -> surface 显示
android
断墨先生10 小时前
uniapp—android原生插件开发(3Android真机调试)
android·uni-app