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
Composable
the performance of your code, using Compose compiler reports and layout inspectors.
Before we dive in, let's Dribbble
take 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 MessageBubble
composition 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 TimelineNode
columns, 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.
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 TimelineNode
the 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
}
)
)
}
}
TimelineNodePosition
An 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 TimelineNode
draw 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.drawBehind
The 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 LineParameters
define 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 LazyColumn
use it in TimelineNode
and 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_compiler
It 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 TimelineNode
combination 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 LazyColumn
to render this dynamic data.
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 LazyColumn
adds a new message at the beginning of the .
Every time a new node is added, we see LazyColumn
a 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