新项目完全用了compose来实现,这两天有个时间轴的需求,搜索了一下完全由compose实现的几个,效果都不算特别好,而且都是用canvas画的,这样的话和原来的view没什么区别,不能发挥compose可定制组合的长处,所以自己实现了一个。由于我自己平时基本不写文章,并且内容也是偏向compose新手的,所以可能写的比较啰嗦,大佬们想看的可以直接跳到第三部分。欢迎指导!
在开始之前,先介绍一下这次实现的重点:Layout。
Layout用于实现自定义的布局,可用于测量和定位其布局子项。我们可以用这个实现之前自定义view的效果,不过这里画的不是点线之类的东西,而是composable,并且只用计算放的位置就好,基于此我们可以实现有多个插槽的布局。
先来看一下UI效果是什么样的
一、分解UI
通过观察UI,我们可以将每个item分解为以下四个元素:圆点、线、时间、内容。一个合格的组件,要允许使用者随意定义各个元素位置的实现,比如圆点可能变成方的,或者换成图片,线也可能是条实线,并且颜色是渐变的。所以这里这几个元素准确的来说,应该是四个插槽,这几个插槽提供了默认的样式是长这样。
圆点槽和时间槽是垂直居中对齐的,圆点槽和线槽是水平居中对齐的,内容槽和时间槽是左对齐,在圆点槽和时间槽中间有一定间距,我们管他叫内容距左间距。
每个item的最大宽度是圆点槽的宽+内容距左间距+内容的宽。每个item的最大高度是圆点或者时间槽的最大高度+内容的高度,不直接用时间槽的高度是因为圆点槽如果放个图片的话,可能高度比时间槽的高度要高。
由于这个线应该是连接两个圆点槽的,所以它的最大高度和最小高度其实都是一个,取决于两个圆点之间的距离,正好是一个item的高度。
在多个item时,第一个元素的线从点开始往下,而最后一个则没有线(说高度为0也行)
二、实现每个插槽的默认UI
- 圆点
这个很简单,任意一个空的组件设置下修饰符就可以了。
kotlin
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape) // 变圆
.background(MaterialTheme.colorScheme.primary)
)
- 线
实线很好实现,也通过background就可以
kotlin
// 实线单色
Box(modifier = Modifier
.width(1.dp)
.fillMaxHeight()
.background(MaterialTheme.colorScheme.primary)
)
// 渐变也简单
Box(
modifier = Modifier
.width(1.dp)
.fillMaxHeight()
.background(
Brush.linearGradient(
listOf(
MaterialTheme.colorScheme.primary,
MaterialTheme.colorScheme.primaryContainer
)
)
)
)
虚线稍微麻烦一点,Brush中没有直接实现虚线的方法,所以我用drawBehind来实现了。drawBehind这里的作用和Canvas()
是一样的,你可以直接用canvas来实现,重点就是里面的pathEffect。
ini
Box(modifier = Modifier
.width(1.dp)
.fillMaxHeight()
.drawBehind {
drawLine(
color = Color.LightGray,
strokeWidth = size.width,
start = Offset(x = 0f, y = 0f),
end = Offset(x = 0f, y = size.height),
pathEffect = PathEffect.dashPathEffect(
floatArrayOf(8.dp.toPx(), 4.dp.toPx())
)
)
}
)
- 时间
简单一个Text就可以。
scss
Text("2023年9月28日")
- 内容
根据具体的内容来实现。
三、通过自定义的Layout将小UI组装起来
现在我们根据第一步的思路,来定义一个组件。
less
@Composable
fun TimelineItem(
modifier: Modifier = Modifier,
dot: @Composable () -> Unit, // 圆点槽
line: @Composable () -> Unit, // 线槽
time: @Composable () -> Unit,// 时间槽
content: @Composable () -> Unit, // 内容槽
contentStartOffset: Dp = 8.dp // 内容距左间距
)
然后我们将第二步中的插槽的默认UI放上去。主要是圆点槽和线槽。
less
@Composable
fun TimelineItem(
modifier: Modifier = Modifier,
dot: @Composable () -> Unit = {
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary)
)
},
line: @Composable () -> Unit = {
Box(modifier = Modifier
.width(1.dp)
.fillMaxHeight()
.drawBehind {
drawLine(
color = Color.LightGray,
strokeWidth = size.width,
start = Offset(x = 0f, y = 0f),
end = Offset(x = 0f, y = size.height),
pathEffect = PathEffect.dashPathEffect(
floatArrayOf(8.dp.toPx(), 4.dp.toPx())
)
)
}
)
},
time: @Composable () -> Unit,
content: @Composable () -> Unit,
contentStartOffset: Dp = 8.dp
)
定义好以后就可以开始做实现了,上面已经说过,我们是通过自定义Layout来实现的,那么先看一下Layout的构成。
less
@UiComposable
@Composable inline fun Layout(
content: @Composable @UiComposable () -> Unit, // 可组合子项。
modifier: Modifier = Modifier, // 布局的修饰符
measurePolicy: MeasurePolicy //布局的测量和定位的策略
)
这其中的content,就是指我们这四个槽的内容。
scss
Layout(
modifier = modifier,
content = {
dot()
// 通过ProvideTextStyle给时间槽提供了一个默认字体颜色。
ProvideTextStyle(value = LocalTextStyle.current.copy(color = Color(0xff999999))) {
time()
}
content()
line()
},
measurePolicy = ...
我们可以看到在content中,我们将四个槽的内容全放进去了,那他们的位置和大小是怎么决定的呢,就是在measurePolicy
中定义的。 MeasurePolicy
类要求我们必须实现measure
方法。
kotlin
fun MeasureScope.measure(
measurables: List<Measurable>,
constraints: Constraints
): MeasureResult
measurables列表中的每个Measurable都对应于布局的一个布局子级,就是我们刚才在content中传入的内容,将按先后顺序存入这个列表。可以使用Measurable.measure
方法来测量子级的大小。该方法需要子级自己所需要的约束Constraints(就是这个子级的最小最大尺寸)
;不同的子级可以用不同的约束来测量,而不是统一用给出的这个constraints参数。测量子级会返回一个Placeable,它的属性有该子级经过对应约束测量后的大小(一旦经过测量,这个子级的大小就确定了,不能再次测量)。最后在MeasureResult中,设置每个子级的位置就可以。
现在我们的代码变成了这样:
less
@Composable
fun TimelineItem(
modifier: Modifier = Modifier,
dot: @Composable () -> Unit = {
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary)
)
},
line: @Composable () -> Unit = {
Box(modifier = Modifier
.width(1.dp)
.fillMaxHeight()
.drawBehind {
drawLine(
color = Color.LightGray,//Color(0xffeeeeee)
strokeWidth = size.width,
start = Offset(x = 0f, y = 0f),
end = Offset(x = 0f, y = size.height),
pathEffect = PathEffect.dashPathEffect(
floatArrayOf(8.dp.toPx(), 4.dp.toPx())
)
)
}
)
},
time: @Composable () -> Unit,
content: @Composable () -> Unit,
contentStartOffset: Dp = 8.dp,
position: TimelinePosition = TimelinePosition.Center
) {
Layout(
modifier = modifier,
content = {
dot()
ProvideTextStyle(value = LocalTextStyle.current.copy(color = Color(0xff999999))) {
time()
}
content()
line()
},
measurePolicy = object : MeasurePolicy {
override fun MeasureScope.measure(
measurables: List<Measurable>,
constraints: Constraints
): MeasureResult {
TODO: 具体的四个子级测量大小的位置设置。
}
}
)
}
现在我们来做具体的实现。 我们先来测量一下这里的圆点槽的大小。 val dot = measurables[0].measure(constraints)
因为我们在content中第一个传入的就是dot(),所以这里measurables[0]就是圆点槽组件,这样就得到了其对应的Placeable。 我们先放置下这个圆点槽显示下看看效果。
kotlin
override fun MeasureScope.measure(
measurables: List<Measurable>,
constraints: Constraints
): MeasureResult {
val dot = measurables[0].measure(constraints)
return layout(constraints.maxWidth, constraints.maxHeight) {
dot.place(0, 0, 1f)
}
}
理论上我们应该看到一个大小8dp,主题色的圆点在左上角。大家可以跑一下看看是不是符合预期。
要指出的是,这个方法给出的constraints并不是合适dot的约束,其最小宽度将可能远远大于dot的宽,这将导致测量后dot的宽远超设定的8dp。所以这里我们需要使用dot正确的约束, 而这个圆点槽理论上是不限制大小的,所以其最小宽度应该设置为0。我们依次将圆,时间,和内容的大小也测量出来。
ini
val constraintsFix = constraints.copy(minWidth = 0)
val dot = measurables[0].measure(constraintsFix)
val time = measurables[1].measure(constraintsFix)
val content = measurables[2].measure(constraintsFix)
之所以不一并把线槽的大小也测量了,是因为我们在第一步中说的,线槽的高度,实际上是由圆点或者时间槽的最大高度+内容的高度来决定的。
ini
val topHeight = max(time.height, dot.height) // 取圆点槽和时间槽中最大槽位的高度。
val lineHigh = topHeight + content.height // 整个组件的高度
val line = measurables[3].measure(
constraints.copy(
minWidth = 0,
minHeight = lineHeight,
maxHeight = lineHeight
)
)
至此我们已经将四个槽位的大小全部确定了下来。接下来就该指定每个槽位的位置,在第一步我们已经分析过每个槽位应该所在的位置。
arduino
val height = topHeight + content.height // 整个组件的高度
// 时间或内容的最大宽度 + 内容距左间距 + 圆点宽度 = 整个组件的宽度
val width =
max(content.width, time.width) + contentStartOffset.roundToPx() + dot.width
return layout(width, height) { // 设置layout占据的大小
val dotY = (topHeight - dot.height) / 2 // 计算圆点槽y轴位置
dot.place(0, dotY, 1f) // 放圆点槽
val timeY = (topHeight - time.height) / 2 // 计算时间槽y轴位置
time.place(dot.width + contentStartOffset.roundToPx(), timeY) // 放时间槽
content.place(dot.width + contentStartOffset.roundToPx(), topHeight) // 放内容槽,x和时间槽一样,形成左对齐效果。
line.place(
dot.width / 2, // x在圆中间
dotY + dot.height // y从圆的最下面开始
)
}
至此我们就有了一个时间轴节点组件,马上在LazyColumn或者Column中试试效果吧!
四、完善效果
如果你刚才测试了效果,你会发现,在列表中最后一个节点,也有虚线,并且长度超出了列表,而最后一个节点,不应该显示虚线才对。所以我们要来完善一下效果。
less
@Composable
fun TimelineItem(
modifier: Modifier = Modifier,
dot: @Composable () -> Unit = ...,
line: @Composable () -> Unit = ...,
time: @Composable () -> Unit,
content: @Composable () -> Unit,
contentStartOffset: Dp = 8.dp,
isEnd: Boolean = false, // 添加是否为最后一个节点的参数
)
...
//在最后根据是否是最后一个节点来设置是否放置线槽内容。
if (!isEnd){
line.place(
dot.width / 2,
dotY + dot.height
)
}
而在调用时,只要简单的根据是否位于列表最后就可以了,调用示例:
ini
LazyColumn(
Modifier
.padding(paddingValues)
.fillMaxSize()
.padding(horizontal = 16.dp)
) {
itemsIndexed(list.itemSnapshotList) { index, item ->
item?.let {
TimelineItem(
modifier = Modifier.fillMaxWidth(),
time = {
Text(text = it.time)
},
content = {
Column {
// 最好在Column最上面和最下面也添加个spacer来间隔开
}
},
isEnd = index == list.itemCount - 1
)
}
}
}
最后
至此本文就结束啦,由于内容比较简单,且所以的代码均有表现,为了不占篇幅,就不再粘贴完整代码内容了。如果本文有错误之处或者可以改进的地方,请大家一定回复指正;如果文章的内容也对你有帮忙,也请回复鼓励我,谢谢大家!