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
            )
        }
    }
}

最后

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

相关推荐
我命由我123451 天前
Kotlin 运算符 - == 运算符与 === 运算符
android·java·开发语言·java-ee·kotlin·android studio·android-studio
我命由我123451 天前
Android Jetpack Compose - TopAppBar、BottomAppBar、Scaffold
android·java·java-ee·kotlin·android studio·android jetpack·android-studio
a3158238061 天前
Android 大图显示策略优化显示(二)
android·java·开发语言·javascript·kotlin·glide·图片加载
资生算法程序员_畅想家_剑魔1 天前
Kotlin常见技术分享-01-相对于Java 的核心优势-空安全
java·安全·kotlin
Android-Flutter2 天前
android compose LazyColumn 垂直列表滚动 使用
android·kotlin
儿歌八万首2 天前
Jetpack Compose 自定义布局解析
kotlin·compose·自定义布局
Kapaseker2 天前
初级与中级的Android面试题区别在哪里
android·kotlin
zFox2 天前
二、Kotlin高级特性以及Compose状态驱动UI
ui·kotlin·compose
PuddingSama3 天前
Gson 很好,但在Kotlin上有更合适的序列化工具「Kotlin Serialization」
android·kotlin·gson
郑梓斌3 天前
Luban 2:简洁高效的Android图片压缩库
微信·kotlin