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 = [email protected]
    }

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

相关推荐
帅得不敢出门2 小时前
Android Framework预装traceroute执行文件到system/bin下
android
xzkyd outpaper3 小时前
从面试角度回答Android中ContentProvider启动原理
android·面试·计算机八股
编程乐学3 小时前
基于Android 开发完成的购物商城App--前后端分离项目
android·android studio·springboot·前后端分离·大作业·购物商城
yours_Gabriel6 小时前
【java面试】微服务篇
java·微服务·中间件·面试·kafka·rabbitmq
这个家伙很笨7 小时前
了解Android studio 初学者零基础推荐(4)
android·ide·android studio
alexhilton9 小时前
在Android应用中实战Repository模式
android·kotlin·android jetpack
天涯学馆9 小时前
工厂模式在 JavaScript 中的深度应用
前端·javascript·面试
巛、10 小时前
ES6面试题
前端·面试·es6
汪子熙11 小时前
走进 Fundamental NGX Platform:从 SAP 设计体系到高生产力组件层
前端·javascript·面试