最近,我们开发一个时间轴组件,显示用户与客户之间的对话。每个对话节点应具有自己的颜色,取决于消息的状态,并且连接消息的线条形成颜色之间的渐变过渡。
我们慷慨地估计了未来的工作,并开始使用Compose来实现它。令人高兴的是,仅仅两个小时后,我们就拥有了一个完全功能的时间轴组件。因此,我们写了这篇文章,为其他开发者提供一些在使用Compose解决类似挑战时的灵感。
简而言之,本文将探讨以下内容:
Modifier.drawBehind()
在Composable内容后绘制到画布中Composable
代码的性能,使用Compose编译器报告和布局检查器。在深入探讨之前,让我们从Dribbble
上的一些时间轴示例中获取一些灵感:
想象一下候选人与人力资源代表之间的对话。虽然已经完成了一些招聘阶段,但仍有未来的阶段要期待。 同时,当前阶段可能也需要您的注意或额外的操作。
这个时间轴实际上就是一列节点。因此,我们最初的重点将是解决如何绘制单个节点。
每个时间轴项目由一个表示时间轴中时刻的圆圈和一些内容(在这种情况下是一条消息)组成。我们希望这个内容是动态的,并且可以从外部传递为参数。因此,我们的时间轴节点不知道我们将在圆圈右侧展示什么内容。
@Composable
fun TimelineNode(
content: @Composable BoxScope.(modifier: Modifier) -> Unit
) {
Box(
modifier = Modifier.wrapContentSize()
) {
content(Modifier)
}
}
为了可视化我们所写的内容,我们将创建一个小预览,其中包含三个节点的列。我们创建一个MessageBubble
组合,并将其用作每个时间轴节点的内容。
@Composable
private fun MessageBubble(modifier: Modifier, containerColor: Color) {
Card(
modifier = modifier
.width(200.dp)
.height(100.dp),
colors = CardDefaults.cardColors(containerColor = containerColor)
) {}
}
@Preview(showBackground = true)
@Composable
private fun TimelinePreview() {
TimelineComposeComponentTheme {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
TimelineNode() { modifier -> MessageBubble(modifier, containerColor = LightBlue) }
TimelineNode() { modifier -> MessageBubble(modifier, containerColor = Purple) }
TimelineNode() { modifier -> MessageBubble(modifier, containerColor = Coral) }
}
}
}
好的,现在我们有了TimelineNode
的列,但它们都紧密地排列在一起。我们需要添加一些间距。
根据设计,每个项目之间应有32dp的间距(我们将这个参数命名为spacerBetweenNodes
)。另外,我们的内容应该与时间轴本身有16dp的偏移(contentStartOffset
)。
此外,我们的节点外观取决于其位置。对于最后一个元素,我们不需要绘制线条或添加间距。为了处理这种情况,我们将定义一个枚举:
enum class TimelineNodePosition {
FIRST,
MIDDLE,
LAST
}
我们将这些额外的参数添加到TimelineNode
的签名中。之后,我们将所传递给内容lambda的modifier应用所需的填充,用于绘制内容。
@Composable
fun TimelineNode(
// 1. we add new parameters here
position: TimelineNodePosition,
contentStartOffset: Dp = 16.dp,
spacerBetweenNodes: Dp = 32.dp,
content: @Composable BoxScope.(modifier: Modifier) -> Unit
) {
Box(
modifier = Modifier.wrapContentSize()
) {
content(
Modifier
.padding(
// 2. we apply our paddings
start = contentStartOffset,
bottom = if (position != TimelineNodePosition.LAST) {
spacerBetweenNodes
} else {
0.dp
}
)
)
}
}
TimelineNodePosition
枚举实际上可以是一个布尔标志,你可能会注意到。是的,可以是布尔标志!如果你对它没有其他用途,可以自由地简化和调整代码以适应你的用例。
我们将相应地调整我们的预览:
@Preview(showBackground = true)
@Composable
private fun TimelinePreview() {
AppTheme {
Column(...) {
TimelineNode(
position = TimelineNodePosition.FIRST,
) { modifier -> MessageBubble(modifier, containerColor = LightBlue) }
TimelineNode(
position = TimelineNodePosition.MIDDLE,
) { modifier -> MessageBubble(modifier, containerColor = Purple) }
TimelineNode(
TimelineNodePosition.LAST
) { modifier -> MessageBubble(modifier, containerColor = Coral) }
}
}
}
通过这些更新,我们的时间轴元素现在有了正确的间距。
很好!接下来,我们要添加漂亮的圆圈,并在每个TimelineNode
的背后绘制渐变线条。
让我们首先定义一个描述我们要绘制的圆圈的类:
data class CircleParameters(
val radius: Dp,
val backgroundColor: Color
)
现在你想知道我们在Compose中需要用什么绘制在Canvas上。有一个修饰符,可以在我们的情况下帮助我们 - Modifier.drawBehind
。
Modifier.drawBehind
允许你在屏幕上绘制Composable内容背后的DrawScope
操作。
你可以在这个页面上阅读更多关于使用绘制修饰符的内容:
https://developer.android.com/jetpack/compose/graphics/draw/modifiers
为了在我们的画布的左上角创建一个圆圈,我们将使用drawCircle()
函数:
@Composable
fun TimelineNode(
// 1. we add a new parameter here
circleParameters: CircleParameters,
...
) {
Box(
modifier = Modifier
.wrapContentSize()
.drawBehind {
// 2. draw a circle here ->
val circleRadiusInPx = circleParameters.radius.toPx()
drawCircle(
color = circleParameters.backgroundColor,
radius = circleRadiusInPx,
center = Offset(circleRadiusInPx, circleRadiusInPx)
)
}
) {
content(...)
}
}
接下来,我们创建一个类来定义线条的外观:
data class LineParameters(
val strokeWidth: Dp,
val brush: Brush
)
现在是时候将我们的圆圈与线条连接起来。我们不需要为最后一个元素绘制线条,因此我们将LineParameters
定义为可为空。我们的线条从圆圈底部到当前项目的底部。
.drawBehind {
val circleRadiusInPx = circleParameters.radius.toPx()
drawCircle(...)
// we added drawing a line here ->
lineParameters?.let{
drawLine(
brush = lineParameters.brush,
start = Offset(x = circleRadiusInPx, y = circleRadiusInPx * 2),
end = Offset(x = circleRadiusInPx, y = this.size.height),
strokeWidth = lineParameters.strokeWidth.toPx()
)
}
为了欣赏我们的工作,我们应该在预览中提供所需的LineParameters
。作为懒惰的开发者,我们不想一遍又一遍地创建渐变刷子,所以我们引入了一个实用对象:
object LineParametersDefaults {
private val defaultStrokeWidth = 3.dp
fun linearGradient(
strokeWidth: Dp = defaultLinearGradient,
startColor: Color,
endColor: Color,
startY: Float = 0.0f,
endY: Float = Float.POSITIVE_INFINITY
): LineParameters {
val brush = Brush.verticalGradient(
colors = listOf(startColor, endColor),
startY = startY,
endY = endY
)
return LineParameters(strokeWidth, brush)
}
}
即使对于圆圈的创建,我们尽管还没有很多用于自定义圆圈的参数,也要做同样的操作:
object CircleParametersDefaults {
private val defaultCircleRadius = 12.dp
fun circleParameters(
radius: Dp = defaultCircleRadius,
backgroundColor: Color = Cyan
) = CircleParameters(radius, backgroundColor)
}
准备好这些实用对象后,让我们更新我们的预览:
@Preview(showBackground = true)
@Composable
private fun TimelinePreview() {
TimelineComposeComponentTheme {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
TimelineNode(
position = TimelineNodePosition.FIRST,
circleParameters = CircleParametersDefaults.circleParameters(
backgroundColor = LightBlue
),
lineParameters = LineParametersDefaults.linearGradient(
startColor = LightBlue,
endColor = Purple
),
) { modifier -> MessageBubble(modifier, containerColor = LightBlue) }
TimelineNode(
position = TimelineNodePosition.MIDDLE,
circleParameters = CircleParametersDefaults.circleParameters(
backgroundColor = Purple
),
lineParameters = LineParametersDefaults.linearGradient(
startColor = Purple,
endColor = Coral
),
) { modifier -> MessageBubble(modifier, containerColor = Purple) }
TimelineNode(
TimelineNodePosition.LAST,
circleParameters = CircleParametersDefaults.circleParameters(
backgroundColor = Coral
),
) { modifier -> MessageBubble(modifier, containerColor = Coral) }
}
}
}
根据您的设计,您可能希望添加图标、描边或其他您可以在画布上绘制的内容。TimelineNode的完整版本具有扩展功能集,可以在GitHub上找到示例。
https://github.com/VitaSokolova/TimelineComposeComponent/blob/master/app/src/main/java/vita/sokolova/timeline/TimelineNode.kt
在我们的预览中,我们手动在列中创建了“TimelineNode”,但您也可以在LazyColumn
中使用TimelineNode
,并根据消息的状态动态填充所有颜色参数。
在UI性能方面,您可能经常会遇到意外的性能下降,这是由于您没有预料到的多余的重组周期造成的。许多非平凡的错误可能导致这种行为。
因此,现在是时候检查我们的Compose可组合是否表现良好。为此,我们首先将使用Compose编译器报告。
要在您的项目中启用Compose编译器报告,请查看本文:
https://developer.android.com/studio/preview/features#compose-compiler-reports
为了调试您的可组合性能稳定性,我们运行以下Gradle任务:
./gradlew assembleRelease -PcomposeCompilerReports=true
它将在您的模块 -> build -> compose_compiler
目录中生成三个输出文件:
首先,让我们检查我们可组合中使用的数据模型的稳定性。我们转到app_release-classes.txt
:
stable class CircleParameters {
stable val radius: Dp
stable val backgroundColor: Color
stable val stroke: StrokeParameters?
stable val icon: Int?
<runtime stability> = Stable
}
stable class LineParameters {
stable val strokeWidth: Dp
stable val brush: Brush
<runtime stability> = Stable
}
非常好!我们在可组合中用作输入参数的所有类都标记为稳定。这是一个非常好的标志,这意味着Compose编译器将了解此类的内容何时发生变化,并仅在必要时触发重组。
接下来,我们检查app_release-composables.txt
:
restartable skippable scheme("[androidx.compose.ui.UiComposable, [androidx.compose.ui.UiComposable]]") fun TimelineNode(
stable position: TimelineNodePosition
stable circleParameters: CircleParameters
stable lineParameters: LineParameters? = @static null
stable contentStartOffset: Dp
stable spacer: Dp
stable content: @[ExtensionFunctionType] Function4<BoxScope, @[ParameterName(name = 'modifier')] Modifier, Composer, Int, Unit>
)
我们的TimelineNode
组合是完全可重启、可跳过和稳定的(因为所有输入参数都是稳定的)。这意味着,Compose将仅在输入参数中的内容真正发生变化时触发重组。
但是我们是不是有点过度担心了?是的,我们是!让我们在布局检查器中运行它,并确保我们没有任何无限循环重组。不要忘记在布局检查器设置中启用“显示重组计数”。
我们添加了一些虚拟数据来显示在我们的时间轴上,并使用LazyColumn
来呈现这些动态数据。
如果我们只是打开我们的应用程序,我们不会看到任何重组发生,这很好。但是让我们对其进行一些压力测试。我们添加了一个浮动操作按钮,该按钮会在LazyColumn
的开头添加新消息。
每次添加新节点时,我们会看到LazyColumn
元素的重组,这是预期的。但是,我们还可以看到,对于某些元素,重组被跳过了,因为它们的内容没有发生变化。这正是我们总是想要实现的,这意味着我们的性能已经足够好了。
我们的工作完成了,我们有了一个漂亮的Compose组件来显示时间轴。它可以从Compose编译器的角度进行自定义和稳定。
https://github.com/VitaSokolova/TimelineComposeComponent