A step-by-step guide to building a timeline component with Jetpack Compose

A step-by-step guide to building a timeline component with Jetpack Compose

Recently, we developed a timeline component that displays conversations between users and customers. Each dialog node should have its own color, depending on the status of the message, and the lines connecting the messages form a gradient transition between the colors.

We generously estimated future work and started using Compose to implement it. Happily, after just two hours, we have a fully functional timeline component. Therefore, we wrote this article to provide some inspiration for other developers when using Compose to solve similar challenges.

In short, this article will explore the following:

  • Create a beautiful timeline component without using any third-party libraries
  • Advanced use Modifier.drawBehind()to draw into canvas after composable content
  • Test Composablethe performance of your code, using Compose compiler reports and layout inspectors.

Before we dive in, let's Dribbbletake some inspiration from some of the timeline examples above:

Imagine a conversation between a candidate and an HR representative. While some recruitment phases have been completed, there are still future phases to look forward to. At the same time, the current stage may also require your attention or extra action.
This timeline is actually a list of nodes. Therefore, our initial focus will be on figuring out how to draw individual nodes.

Each timeline item consists of a circle representing a moment in the timeline and some content (in this case a message). We want this content to be dynamic and passable as a parameter from outside. So our timeline node doesn't know what we're going to show to the right of the circle.

@Composable
fun TimelineNode(
    content: @Composable BoxScope.(modifier: Modifier) -> Unit
) {
    
    
    Box(
        modifier = Modifier.wrapContentSize()
    ) {
    
    
        content(Modifier)
    }
}

To visualize what we've written, we'll create a small preview with columns of three nodes. We create a MessageBubblecomposition and use it as the content of each timeline node.

@Composable
private fun MessageBubble(modifier: Modifier, containerColor: Color) {
    
    
    Card(
        modifier = modifier
            .width(200.dp)
            .height(100.dp),
        colors = CardDefaults.cardColors(containerColor = containerColor)
    ) {
    
    }
}
@Preview(showBackground = true)
@Composable
private fun TimelinePreview() {
    
    
    TimelineComposeComponentTheme {
    
    
        Column(
            modifier = Modifier
                .fillMaxWidth()
                .padding(16.dp)
        ) {
    
    
            TimelineNode() {
    
     modifier -> MessageBubble(modifier, containerColor = LightBlue) }
            TimelineNode() {
    
     modifier -> MessageBubble(modifier, containerColor = Purple) }
            TimelineNode() {
    
     modifier -> MessageBubble(modifier, containerColor = Coral) }
        }
    }
}

Ok, so now we have TimelineNodecolumns, but they're all tightly packed together. We need to add some spacing.

Step 1: Add Spacing

By design, there should be 32dp of spacing between each item (we named this parameter spacerBetweenNodes). Also, our content should have a 16dp offset ( contentStartOffset) from the timeline itself.
Schematic diagram of spacerBetweenNodes and contentStartOffset parameters

Also, our node's appearance depends on its position. For the last element, we don't need to draw lines or add spacing. To handle this case, we'll define an enum:

enum class TimelineNodePosition {
    
    
    FIRST,
    MIDDLE,
    LAST
}

We add these extra parameters to TimelineNodethe signature of . Afterwards, we apply the desired padding to the modifier passed to the content lambda for drawing the content.

@Composable
fun TimelineNode(
    // 1. we add new parameters here
    position: TimelineNodePosition,
    contentStartOffset: Dp = 16.dp,
    spacerBetweenNodes: Dp = 32.dp,
    content: @Composable BoxScope.(modifier: Modifier) -> Unit
) {
    
    
    Box(
        modifier = Modifier.wrapContentSize()
    ) {
    
    
        content(
            Modifier
                .padding(
                    // 2. we apply our paddings
                    start = contentStartOffset,
                    bottom = if (position != TimelineNodePosition.LAST) {
    
    
                        spacerBetweenNodes
                    } else {
    
    
                        0.dp
                    }
                )
        )
    }
}

TimelineNodePositionAn enum can actually be a boolean flag, you might notice. Yes, it can be a boolean flag! If you have no other use for it, feel free to simplify and adapt the code to suit your use case.

We'll adjust our preview accordingly:

@Preview(showBackground = true)
@Composable
private fun TimelinePreview() {
    
    
    AppTheme {
    
    
        Column(...) {
    
    
            TimelineNode(
                position = TimelineNodePosition.FIRST,
            ) {
    
     modifier -> MessageBubble(modifier, containerColor = LightBlue) }

            TimelineNode(
                position = TimelineNodePosition.MIDDLE,
            ) {
    
     modifier -> MessageBubble(modifier, containerColor = Purple) }

            TimelineNode(
                TimelineNodePosition.LAST
            ) {
    
     modifier -> MessageBubble(modifier, containerColor = Coral) }
        }
    }
}

With these updates, our timeline elements now have the correct spacing.

very good! Next, we're going to add nice circles and TimelineNodedraw gradient lines behind each one.

Step 2: Draw the circle

Let's first define a class that describes the circle we want to draw:

data class CircleParameters(
    val radius: Dp,
    val backgroundColor: Color
)

Now you are wondering what we need to draw on the Canvas in Compose. There is a modifier that helps us in our case - Modifier.drawBehind.

Modifier.drawBehindThe operations behind allowing you to draw Composable content on the screen DrawScope.

You can read more about using draw modifiers on this page:

https://developer.android.com/jetpack/compose/graphics/draw/modifiers

In order to create a circle in the upper left corner of our canvas, we'll use drawCircle()the function:

@Composable
fun TimelineNode(
    // 1. we add a new parameter here
    circleParameters: CircleParameters,
    ...
) {
    
    
    Box(
        modifier = Modifier
            .wrapContentSize()
            .drawBehind {
    
    
                // 2. draw a circle here ->
                val circleRadiusInPx = circleParameters.radius.toPx()
                drawCircle(
                    color = circleParameters.backgroundColor,
                    radius = circleRadiusInPx,
                    center = Offset(circleRadiusInPx, circleRadiusInPx)
                )
            }
    ) {
    
    
        content(...)
    }
}

We now have beautiful circles on our timeline canvas!

Step 3: Draw the Lines

Next, we create a class to define the appearance of the line:

data class LineParameters(
    val strokeWidth: Dp,
    val brush: Brush
)

Now it's time to connect our circles with lines. We don't need to draw a line for the last element, so we'll LineParametersdefine it as nullable. Our line goes from the bottom of the circle to the bottom of the current item.

.drawBehind {
    
    
    val circleRadiusInPx = circleParameters.radius.toPx()
    drawCircle(...)
    // we added drawing a line here ->
    lineParameters?.let{
    
    
        drawLine(
            brush = lineParameters.brush,
            start = Offset(x = circleRadiusInPx, y = circleRadiusInPx * 2),
            end = Offset(x = circleRadiusInPx, y = this.size.height),
            strokeWidth = lineParameters.strokeWidth.toPx()
        )
    
}

In order to appreciate our work, we should provide what is needed in the preview LineParameters. As lazy developers, we don't want to create gradient brushes over and over again, so we introduce a utility object:

object LineParametersDefaults {
    
    

    private val defaultStrokeWidth = 3.dp

    fun linearGradient(
        strokeWidth: Dp = defaultLinearGradient,
        startColor: Color,
        endColor: Color,
        startY: Float = 0.0f,
        endY: Float = Float.POSITIVE_INFINITY
    ): LineParameters {
    
    
        val brush = Brush.verticalGradient(
            colors = listOf(startColor, endColor),
            startY = startY,
            endY = endY
        )
        return LineParameters(strokeWidth, brush)
    }
}

Even for the creation of the circle, we do the same although we don't have many parameters for customizing the circle yet:

object CircleParametersDefaults {
    
    

    private val defaultCircleRadius = 12.dp

    fun circleParameters(
        radius: Dp = defaultCircleRadius,
        backgroundColor: Color = Cyan
    ) = CircleParameters(radius, backgroundColor)
}

With these utility objects ready, let's update our preview:

@Preview(showBackground = true)
@Composable
private fun TimelinePreview() {
    
    
    TimelineComposeComponentTheme {
    
    
        Column(
            modifier = Modifier
                .fillMaxWidth()
                .padding(16.dp)
        ) {
    
    
            TimelineNode(
                position = TimelineNodePosition.FIRST,
                circleParameters = CircleParametersDefaults.circleParameters(
                    backgroundColor = LightBlue
                ),
                lineParameters = LineParametersDefaults.linearGradient(
                    startColor = LightBlue,
                    endColor = Purple
                ),
            ) {
    
     modifier -> MessageBubble(modifier, containerColor = LightBlue) }

            TimelineNode(
                position = TimelineNodePosition.MIDDLE,
                circleParameters = CircleParametersDefaults.circleParameters(
                    backgroundColor = Purple
                ),
                lineParameters = LineParametersDefaults.linearGradient(
                    startColor = Purple,
                    endColor = Coral
                ),
            ) {
    
     modifier -> MessageBubble(modifier, containerColor = Purple) }

            TimelineNode(
                TimelineNodePosition.LAST,
                circleParameters = CircleParametersDefaults.circleParameters(
                    backgroundColor = Coral
                ),
            ) {
    
     modifier -> MessageBubble(modifier, containerColor = Coral) }
        }
    }
}

We can now admire the colorful gradients between the timeline elements.

(optional step): Go crazy and add extra decorations

Depending on your design, you may wish to add icons, strokes, or other content that you can draw on the canvas. The full version of TimelineNode has an extended feature set and examples can be found on GitHub.

https://github.com/VitaSokolova/TimelineComposeComponent/blob/master/app/src/main/java/vita/sokolova/timeline/TimelineNode.kt

In our preview, we manually created the "TimelineNode" in the column, but you can also LazyColumnuse it in TimelineNodeand dynamically fill all the color parameters based on the status of the message.

Check stability with Compose compiler report

When it comes to UI performance, you may often experience unexpected performance drops due to redundant reorganization cycles that you didn't anticipate. Many non-trivial errors can cause this behavior.

So now is the time to check if our Compose composability is performing well. To do this, we will first use the Compose compiler report.

To enable Compose compiler reporting in your project, check out this article:

https://developer.android.com/studio/preview/features#compose-compiler-reports

To debug your composable performance stability, we run the following Gradle tasks:

./gradlew assembleRelease -PcomposeCompilerReports=true

-> build -> compose_compilerIt will generate three output files in your module directory:

First, let's check the stability of the data model we use in composable. We go to app_release-classes.txt:

stable class CircleParameters {
    
    
  stable val radius: Dp
  stable val backgroundColor: Color
  stable val stroke: StrokeParameters?
  stable val icon: Int?
  <runtime stability> = Stable
}
stable class LineParameters {
    
    
  stable val strokeWidth: Dp
  stable val brush: Brush
  <runtime stability> = Stable
}

very good! All classes that we use as input parameters in composable are marked as stable. This is a very good flag and means the Compose compiler will know when the contents of this class have changed and will only trigger recomposition if necessary.

Next, we check app_release-composables.txt:

restartable skippable scheme("[androidx.compose.ui.UiComposable, [androidx.compose.ui.UiComposable]]") fun TimelineNode(
  stable position: TimelineNodePosition
  stable circleParameters: CircleParameters
  stable lineParameters: LineParameters? = @static null
  stable contentStartOffset: Dp
  stable spacer: Dp
  stable content: @[ExtensionFunctionType] Function4<BoxScope, @[ParameterName(name = 'modifier')] Modifier, Composer, Int, Unit>
)

Our TimelineNodecombination is fully restartable, skippable and stable (since all input parameters are stable). This means, Compose will only trigger recomposition when the content in the input parameters actually changes.

Check the number of reorganizations using the layout inspector

But aren't we worrying a little too much? Yes we are! Let's run this in the layout inspector and make sure we don't have any infinite loop reorganizations. Don't forget to enable "Show reorganization count" in the layout inspector settings.

We added some dummy data to display on our timeline and used LazyColumnto render this dynamic data.
No recompositions happen on static list of elements

If we just open our app, we don't see any reorganization happening, which is fine. But let's put it to some stress tests. We've added a floating action button that LazyColumnadds a new message at the beginning of the .

Every time a new node is added, we see LazyColumna reorganization of the elements, which is expected. However, we can also see that for some elements, the reorganization was skipped because their content did not change. This is exactly what we always want to achieve, which means our performance is good enough.

in conclusion

Our work is done and we have a beautiful Compose component to display the timeline. It is customizable and stable from the perspective of the Compose compiler.

GitHub

https://github.com/VitaSokolova/TimelineComposeComponent

Guess you like

Origin blog.csdn.net/u011897062/article/details/132047618