Jetpack Compose (二) ——— Compose 自定义 View、自定义布局

715 阅读3分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

一、声明式 UI

在提到 Compose 时,我们总是会听到这是一个声明式的 UI 库。所谓声明式 UI,是和以前的命令式 UI 相对的概念。两者的区别是:

  • 在 View 体系编写的 UI 中,当页面发生改变时,开发者需要手动调用 view.setXXX 方法去更新 UI。
  • 在 Compose 体系编写的 UI 中,Compose 只负责描述页面的当前状态,只要把当前状态的参数告诉 Compose,它就会根据当前状态绘制出当前的页面。Compose 中的控件无法通过 id 获取到实例,无法通过 view.setXXX 方法更新 UI 控件。

声明式 UI 的一个重要理念是:你只负责根据当前状态描述当前的 UI,框架负责在状态改变时更新 UI。

二、自定义 View

在 View 体系中,编写自定义 View 通常有三种方式:继承布局、继承原生控件、继承 View 手动绘制。

在 Compose 中,通常不再需要使用继承的方式,许多控件本身就支持放置其他控件,比如 Button() 函数:

Button(onClick = {}) {
    Text(text = "1")
}

Button() 函数的最后一个参数是 content: @Composable RowScope.() -> Unit,表示一个水平的 Layout,可以在其中随意放置其他控件。实现以前继承于 Button 的自定义 View 的效果。

在 Compose 中绘制 View 和以前是很相似的,通过在 Canvas 中调用 drawXXX 方法即可。

class MainActivity : ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            ProgressCircle(270f)
        }
    }

    @Composable
    fun ProgressCircle(sweepAngle: Float) {
        // Circle diameter
        val size = 160.dp
        Box(modifier = Modifier.padding(16.dp), contentAlignment = Alignment.Center) {
            Canvas(
                modifier = Modifier.size(size)
            ) {
                // Circle radius
                val r = size.toPx() / 2
                // The width of Ring
                val stokeWidth = 12.dp.toPx()
                // Draw dial plate
                drawCircle(
                    color = Color.LightGray,
                    style = Stroke(
                        width = stokeWidth,
                        pathEffect = PathEffect.dashPathEffect(
                            intervals = floatArrayOf(1.dp.toPx(), 3.dp.toPx())
                        )
                    )
                )
                // Draw ring
                drawArc(
                    brush = Brush.sweepGradient(
                        0f to Color.Magenta,
                        0.5f to Color.Blue,
                        0.75f to Color.Green,
                        0.75f to Color.Transparent,
                        1f to Color.Magenta
                    ),
                    startAngle = -90f,
                    sweepAngle = sweepAngle,
                    useCenter = false,
                    style = Stroke(
                        width = stokeWidth
                    ),
                    alpha = 0.5f
                )
                // Pointer
                val angle = (360 - sweepAngle) / 180 * Math.PI
                val pointerTailLength = 8.dp.toPx()
                drawLine(
                    color = Color.Red,
                    start = Offset(r + pointerTailLength * sin(angle).toFloat(), r + pointerTailLength * cos(angle).toFloat()),
                    end = Offset((r - r * sin(angle) - sin(angle) * stokeWidth / 2).toFloat(), (r - r * cos(angle) - cos(angle) * stokeWidth / 2).toFloat()),
                    strokeWidth = 2.dp.toPx()
                )
                drawCircle(
                    color = Color.Red,
                    radius = 5.dp.toPx()
                )
                drawCircle(
                    color = Color.White,
                    radius = 3.dp.toPx()
                )
            }
        }
    }
}

在这个例子中,我们绘制了一个表盘。通过 drawCircle 绘制圆形,drawArc 绘制弧线,drawLine 绘制直线。在绘制时,通过 pathEffect 设置绘制效果,通过 brush 参数设置笔刷的样式。

最终效果如下:

plate

三、自定义布局

Compose 中,界面元素都绘制在界面树中,每个界面元素都有一个父元素,还可能有多个子元素。每个元素在父元素中都有一个位置,指定为 (x, y) 位置,还有一个尺寸,指定为 width 和 height。

自定义一个 MyColumn 的代码如下:

class MainActivity : ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MyColumn {
                Text(text = "Hello")
                Text(text = "Hello")
                Text(text = "Hello")
            }
        }
    }

    @Composable
    fun MyColumn(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
        Layout(modifier = modifier, content = content) { measurables, constraints ->
            val placeables = measurables.map { it.measure(constraints) }
            var y = 0
            layout(constraints.maxWidth, constraints.maxHeight) {
                placeables.forEach {
                    it.placeRelative(0, y)
                    y += it.height
                }
            }
        }
    }
}

在这份代码中,我们通过 Layout() 函数构建一个自定义布局。它需要三个参数:

  • modifier 我们已经很熟悉了,表示修饰器,设置 padding、size 之类的
  • content 表示子元素
  • MeasurePolicy 表示布局方式。

前两个元素由 MyColumn 参数传入,着重看一下第三个参数。

首先通过 measurables 的 measure(constraints) 函数测量出每个元素的位置。然后通过 layout() 函数设置整个布局的宽高,我们将宽高指定为 constraints.maxWidth, constraints.maxHeight,表示此布局的最大宽高。

在 layout 函数中,遍历所有的元素,将其位置调整为 (0, y),其中 y 每次增加此元素的高度。这样就能实现类似 Column 的效果。

运行程序,显示如下:

MyColumn

可以看出,在 Compose 中,自定义 View 和自定义布局的代码量都不多,熟练掌握之后可以提升开发效率。