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

最后

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

相关推荐
夏非夏2 天前
Kotlin jetpack MVP
android·kotlin
zhangphil2 天前
Kotlin约束泛型参数必须继承自某个父类
kotlin
ch_kexin2 天前
Android kotlin integer-array 存放图片资源ID
android·开发语言·kotlin
jiay23 天前
Kotlin-面向对象之构造函数、实例化和初始化
android·开发语言·kotlin
我怀里的猫3 天前
glide ModelLoader的Key错误使用 可能造成的内存泄漏
android·kotlin·glide
陟彼高冈yu4 天前
第10天:Fragments(碎片)使用-补充材料——‘MainActivity.kt‘解读
android·kotlin·android studio
姑苏风4 天前
《Kotlin实战》-第11章:DSL构建
android·开发语言·kotlin
大耳猫4 天前
Android 解决Java和Kotlin JDK编译版本不一致异常
android·java·kotlin
萌面小侠Plus6 天前
Android笔记(三十五):用责任链模式封装一个App首页Dialog管理工具
android·dialog·笔记·kotlin·责任链模式
bytebeats6 天前
Kotlin 中注解的主要实现方式
android·kotlin