Blog Infos
Author
Published
Topics
Published
Topics
Gemini generated

Custom drawing is useful for when the built-in components just don’t cover exactly what our app needs. This article provides a guide to create a custom sleep timeline graph, similar to those you can find in the Fitbit app.

Screenshot from Fitbit Android app
How do we draw in Compose?

To get started with drawing in Compose we can use drawing modifiers or Canvas composable which gives us DrawScope — a declarative, stateless API to draw shapes and paths without requiring consumers to maintain underlying state. DrawScope implementations are also provided sizing information and transformations are done relative to the local translation.

Note: Jetpack Compose (Android only) and Compose Multiplatform (Desktop, Android, iOS, web) have similar drawing API. Screenshots below are made on Desktop (macOS), but the result is the same on all platforms (check the last screenshot).

Canvas(modifier = Modifier.fillMaxSize()) {
rotate(degrees = 45F) {
drawRect(
color = Color.Gray,
topLeft = Offset(x = size.width / 3F, y = size.height / 3F),
size = size / 3F
)
}
}
What is the sleep timeline?

We can read or write sleep data in Health Connect. Sleep data is displayed as a session, and can be divided into sleep stages:

  • Awake: the user is awake within a sleep cycle.
  • Light sleep: the user is in a light sleep cycle.
  • Deep sleep: the user is in a deep sleep cycle.
  • REM: the user is in a REM sleep cycle.

These values represent the type of sleep a user experiences within a time range.

The SleepSessionRecord data type has two parts:

  1. The overall session, spanning the entire duration of sleep.
  2. Individual stages during the sleep session such as light sleep or deep sleep.
val record = remember {
SleepSessionRecord(
startTime = Instant.parse("2025-01-28T21:10:10Z"),
endTime = Instant.parse("2025-01-29T07:32:13Z"),
startZoneOffset = UtcOffset(hours = 2),
endZoneOffset = UtcOffset(hours = 2),
stages = listOf(
SleepSessionRecord.Stage(
startTime = Instant.parse("2025-01-28T21:10:10Z"),
endTime = Instant.parse("2025-01-28T23:15:13Z"),
type = SleepSessionStageType.Light,
),
SleepSessionRecord.Stage(
startTime = Instant.parse("2025-01-28T23:15:13Z"),
endTime = Instant.parse("2025-01-29T01:56:32Z"),
type = SleepSessionStageType.Deep,
),
SleepSessionRecord.Stage(
startTime = Instant.parse("2025-01-29T01:56:13Z"),
endTime = Instant.parse("2025-01-29T03:16:22Z"),
type = SleepSessionStageType.Light,
),
SleepSessionRecord.Stage(
startTime = Instant.parse("2025-01-29T03:16:22Z"),
endTime = Instant.parse("2025-01-29T04:32:13Z"),
type = SleepSessionStageType.REM,
),
SleepSessionRecord.Stage(
startTime = Instant.parse("2025-01-29T04:32:13Z"),
endTime = Instant.parse("2025-01-29T05:12:56Z"),
type = SleepSessionStageType.Deep,
),
SleepSessionRecord.Stage(
startTime = Instant.parse("2025-01-29T05:12:56Z"),
endTime = Instant.parse("2025-01-29T07:32:13Z"),
type = SleepSessionStageType.Light,
),
SleepSessionRecord.Stage(
startTime = Instant.parse("2025-01-28T22:11:56Z"),
endTime = Instant.parse("2025-01-28T22:17:13Z"),
type = SleepSessionStageType.Awake,
),
SleepSessionRecord.Stage(
startTime = Instant.parse("2025-01-28T22:39:56Z"),
endTime = Instant.parse("2025-01-28T22:51:13Z"),
type = SleepSessionStageType.Awake,
),
SleepSessionRecord.Stage(
startTime = Instant.parse("2025-01-29T04:47:56Z"),
endTime = Instant.parse("2025-01-29T04:54:13Z"),
type = SleepSessionStageType.Awake,
),
),
)
}
Math

During the sleep session we can be in the same stage many times at different moments in time. We need to calculate the start and end points relative to the sleep session.

To draw a rect in Compose we need topOffset and size .

private fun calculate(
canvasSize: Size,
recordStartTime: Instant,
recordEndTime: Instant,
stages: List<SleepSessionRecord.Stage>,
): List<SleepStageDrawPoint> {
val totalDuration = (recordEndTime - recordStartTime).inWholeSeconds.toFloat()
.coerceAtLeast(1f)
return stages.map { stage ->
val stageOffset =
(stage.startTime - recordStartTime).inWholeSeconds / totalDuration
val stageDuration =
(stage.endTime - stage.startTime).inWholeSeconds.toFloat() / totalDuration
SleepStageDrawPoint(
topLeft = Offset(x = canvasSize.width * stageOffset, y = 0f),
size = canvasSize.copy(width = canvasSize.width * stageDuration),
)
}
}
Drawing

Let’s build our custom Canvas to draw one stage of the sleep session, e.g. deep.

@Composable
fun SleepSessionCanvas(
modifier: Modifier,
record: SleepSessionRecord,
) {
Spacer(
modifier = modifier.drawWithCache {
val points = calculate(
canvasSize = size,
recordStartTime = record.startTime,
recordEndTime = record.endTime,
stages = record.stages.filter { it.type == SleepSessionStageType.Deep },
)
onDrawWithContent {
// Draw background
drawRoundRect(
color = Color.LightGray,
topLeft = Offset(x = 0f, y = size.height / 4f),
size = size.copy(height = size.height / 2f),
cornerRadius = CornerRadius(size.height / 2f),
)
// Draw stage points
points.forEach { point ->
drawRect(
topLeft = point.topLeft,
size = point.size,
color = Color(0xFF673AB7),
)
}
}
}
)
}

If we run the project with the previously defined sleep session, we will see 3 rects: 1 grey rect for background and 2 purple rects for deep sleep stage.

SleepSessionCanvas(
modifier = Modifier
.fillMaxWidth()
.height(320.dp)
.padding(16.dp),
record = record,
)

To draw all stages of the sleep session (awake, REM, light, and deep) we need to make a few adjustments to draw each stage type as Column component, vertically, by drawing line by line and applying some offset for the next line.

@Composable
fun SleepSessionCanvas(
modifier: Modifier,
record: SleepSessionRecord,
stageHeight: Dp = 48.dp,
stagesSpacing: Dp = 16.dp,
) {
val colors = remember {
mapOf(
SleepSessionStageType.Awake to Color(0xFFFF9800),
SleepSessionStageType.Light to Color(0xFF2196F3),
SleepSessionStageType.Deep to Color(0xFF673AB7),
SleepSessionStageType.REM to Color(0xFF795548),
)
}
val stageHeightPx = with(LocalDensity.current) { stageHeight.toPx() }
val stagesSpacingPx = with(LocalDensity.current) { stagesSpacing.toPx() }
Spacer(
modifier = modifier
.requiredHeight(stageHeight * colors.size + stagesSpacing * (colors.size - 1))
.drawWithCache {
val stages = listOf(
SleepSessionStageType.Awake,
SleepSessionStageType.REM,
SleepSessionStageType.Light,
SleepSessionStageType.Deep,
).map { type ->
type to calculate(
canvasSize = size.copy(height = stageHeightPx),
recordStartTime = record.startTime,
recordEndTime = record.endTime,
stages = record.stages.filter { it.type == type },
)
}
onDrawWithContent {
var offset = 0f
stages.forEach { (type, points) ->
translate(top = offset) {
// Draw background
drawRoundRect(
color = Color.LightGray,
topLeft = Offset(x = 0f, y = stageHeightPx / 4),
size = size.copy(height = stageHeightPx / 2),
cornerRadius = CornerRadius(stageHeightPx / 2),
)
// Draw stage points
points.forEach { point ->
drawRect(
topLeft = point.topLeft,
size = point.size,
color = colors.getValue(type),
)
}
}
offset += stageHeightPx + stagesSpacingPx
}
}
}
)
}

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

No results found.

Jobs

No results found.

Draw text

To draw a text in Compose, we can typically use the Text composable. However, in our example we are in a DrawScope and we can use the DrawScope.drawText() method.

Drawing text works a bit differently from other drawing commands. Normally, we give the drawing command the size (width and height) to draw the shape/image as. With text, there are a few parameters that control the size of the rendered text, such as font size, font, ligatures, and letter spacing. We need to use a TextMeasurer to get access to the measured size of text, depending on the above factors.

Please find the full example in my repository: https://github.com/vitoksmile/Sleep-timeline-graph

This article is previously published on proandroiddev.com.

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
It’s one of the common UX across apps to provide swipe to dismiss so…
READ MORE
blog
In this part of our series on introducing Jetpack Compose into an existing project,…
READ MORE
blog
This is the second article in an article series that will discuss the dependency…
READ MORE
blog
Let’s suppose that for some reason we are interested in doing some tests with…
READ MORE
Menu