compose 实现时间轴效果

新项目完全用了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
            )
        }
    }
}

最后

至此本文就结束啦,由于内容比较简单,且所以的代码均有表现,为了不占篇幅,就不再粘贴完整代码内容了。如果本文有错误之处或者可以改进的地方,请大家一定回复指正;如果文章的内容也对你有帮忙,也请回复鼓励我,谢谢大家!

相关推荐
alexhilton1 天前
端侧RAG实战指南
android·kotlin·android jetpack
Kapaseker2 天前
2026年,我们还该不该学编程?
android·kotlin
Kapaseker3 天前
一杯美式搞懂 Any、Unit、Nothing
android·kotlin
Kapaseker4 天前
一杯美式搞定 Kotlin 空安全
android·kotlin
FunnySaltyFish5 天前
什么?Compose 把 GapBuffer 换成了 LinkBuffer?
算法·kotlin·android jetpack
Kapaseker5 天前
Compose 进阶—巧用 GraphicsLayer
android·kotlin
Kapaseker6 天前
实战 Compose 中的 IntrinsicSize
android·kotlin
A0微声z8 天前
Kotlin Multiplatform (KMP) 中使用 Protobuf
kotlin
alexhilton8 天前
使用FunctionGemma进行设备端函数调用
android·kotlin·android jetpack
lhDream9 天前
Kotlin 开发者必看!JetBrains 开源 LLM 框架 Koog 快速上手指南(含示例)
kotlin