新项目彻底用了compose来完成,这两天有个时刻轴的需求,搜索了一下彻底由compose完成的几个,作用都不算特别好,而且都是用canvas画的,这样的话和本来的view没什么差异,不能发挥compose可定制组合的长处,所以自己完成了一个。因为我自己平常基本不写文章,而且内容也是倾向compose新手的,所以或许写的比较烦琐,大佬们想看的能够直接跳到第三部分。欢迎指导!

在开端之前,先介绍一下这次完成的重点:Layout

Layout用于完成自界说的布局,可用于丈量和定位其布局子项。咱们能够用这个完成之前自界说view的作用,不过这儿画的不是点线之类的东西,而是composable,而且只用核算放的方位就好,基于此咱们能够完成有多个插槽的布局。

先来看一下UI作用是什么样的

compose 实现时间轴效果

一、分化UI

通过观察UI,咱们能够将每个item分化为以下四个元素:圆点、线、时刻、内容。一个合格的组件,要允许运用者随意界说各个元素方位的完成,比如圆点或许变成方的,或许换成图片,线也或许是条实线,而且色彩是突变的。所以这儿这几个元素准确的来说,应该是四个插槽,这几个插槽供给了默许的款式是长这样。

圆点槽和时刻槽是垂直居中对齐的,圆点槽和线槽是水平居中对齐的,内容槽和时刻槽是左对齐,在圆点槽和时刻槽中心有必定间隔,咱们管他叫内容距左间隔。

每个item的最大宽度是圆点槽的宽+内容距左间隔+内容的宽。每个item的最大高度是圆点或许时刻槽的最大高度+内容的高度,不直接用时刻槽的高度是因为圆点槽假如放个图片的话,或许高度比时刻槽的高度要高。

因为这个线应该是连接两个圆点槽的,所以它的最大高度和最小高度其实都是一个,取决于两个圆点之间的间隔,正好是一个item的高度。

在多个item时,第一个元素的线从点开端往下,而最终一个则没有线(说高度为0也行)

二、完成每个插槽的默许UI

  • 圆点

这个很简略,任意一个空的组件设置下修饰符就能够了。

Box(
    modifier = Modifier
        .size(8.dp)
        .clip(CircleShape) // 变圆
        .background(MaterialTheme.colorScheme.primary)
)
  • 线

实线很好完成,也通过background就能够

// 实线单色
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。

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就能够。

Text("2023928日")
  • 内容

依据详细的内容来完成。

三、通过自界说的Layout将小UI拼装起来

现在咱们依据第一步的思路,来界说一个组件。

@Composable
fun TimelineItem(
    modifier: Modifier = Modifier,
    dot: @Composable () -> Unit, // 圆点槽
    line: @Composable () -> Unit, // 线槽
    time: @Composable () -> Unit,// 时刻槽
    content: @Composable () -> Unit, // 内容槽
    contentStartOffset: Dp = 8.dp // 内容距左间隔
) 

然后咱们将第二步中的插槽的默许UI放上去。主要是圆点槽和线槽。

@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的构成。

@UiComposable
@Composable inline fun Layout(
    content: @Composable @UiComposable () -> Unit, // 可组合子项。
    modifier: Modifier = Modifier, // 布局的修饰符
    measurePolicy: MeasurePolicy //布局的丈量和定位的策略
) 

这其间的content,便是指咱们这四个槽的内容。

Layout(
    modifier = modifier,
    content = {
        dot()
        // 通过ProvideTextStyle给时刻槽供给了一个默许字体色彩。
        ProvideTextStyle(value = LocalTextStyle.current.copy(color = Color(0xff999999))) {
            time()
        }
        content()
        line()
    },
    measurePolicy = ...

咱们能够看到在content中,咱们将四个槽的内容全放进去了,那他们的方位和巨细是怎么决议的呢,便是在measurePolicy中界说的。 MeasurePolicy类要求咱们有必要完成measure方法。

fun MeasureScope.measure(
    measurables: List<Measurable>,
    constraints: Constraints
): MeasureResult

measurables列表中的每个Measurable都对应于布局的一个布局子级,便是咱们刚才在content中传入的内容,将按先后顺序存入这个列表。能够运用Measurable.measure方法来丈量子级的巨细。该方法需求子级自己所需求的束缚Constraints(便是这个子级的最小最大尺度);不同的子级能够用不同的束缚来丈量,而不是统一用给出的这个constraints参数。丈量子级会回来一个Placeable,它的属性有该子级通过对应束缚丈量后的巨细(一旦通过丈量,这个子级的巨细就确认了,不能再次丈量)。最终在MeasureResult中,设置每个子级的方位就能够。

现在咱们的代码变成了这样:

@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。 咱们先放置下这个圆点槽显现下看看作用。

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。咱们依次将圆,时刻,和内容的巨细也丈量出来。

val constraintsFix = constraints.copy(minWidth = 0)
val dot = measurables[0].measure(constraintsFix)
val time = measurables[1].measure(constraintsFix)
val content = measurables[2].measure(constraintsFix)

之所以纷歧并把线槽的巨细也丈量了,是因为咱们在第一步中说的,线槽的高度,实际上是由圆点或许时刻槽的最大高度+内容的高度来决议的。

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

至此咱们现已将四个槽位的巨细全部确认了下来。接下来就该指定每个槽位的方位,在第一步咱们现已分析过每个槽位应该地点的方位。

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中试试作用吧!

四、完善作用

假如你刚才测试了作用,你会发现,在列表中最终一个节点,也有虚线,而且长度超出了列表,而最终一个节点,不应该显现虚线才对。所以咱们要来完善一下作用。

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

而在调用时,只要简略的依据是否坐落列表最终就能够了,调用示例:

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

最终

至此本文就完毕啦,因为内容比较简略,且所以的代码均有体现,为了不占篇幅,就不再张贴完好代码内容了。假如本文有错误之处或许能够改善的地方,请咱们必定回复指正;假如文章的内容也对你有帮忙,也请回复鼓舞我,谢谢咱们!