本文译自Draw sleep timeline graph in Compose,原文由Viktor Mykhailiv发布于2025年1月31日。
译者按: 我们在前面的降Compose十八掌系列中讲解过在Compose自定义绘制的方法,可以先温习一下上一篇文章。这篇文章是提升自定义绘制技巧的一个非常好的实战例子。
当内置组件不能完全满足我们的应用需求时,自定义绘图非常有用。本文提供了创建自定义睡眠时间线图表的指南,类似于你在Fitbit 应用中找到的图表。
在 Compose 中如何绘图?
要开始在 Compose 中绘图,我们可以使用绘图Modifier或 Canvas可组合函数,这为我们提供了 DrawScope --- 一种声明式、无状态的API,用于绘制形状和路径,而无需消费者维护底层状态。DrawScope实现还提供了尺寸信息,并且变幻是相对于本地平移完成的。
注意: Jetpack Compose(仅限 Android)和 Compose Multiplatform(桌面、Android、iOS、Web)具有类似的绘图 API。下面的屏幕截图是在桌面(macOS)上制作的,但所有平台上的结果都是相同的(查看最后一张屏幕截图)。
Kotlin
Canvas(modifier = Modifier.fillMaxSize()) {
rotate(degrees = 45F) {
drawRect(
color = Color.Gray,
topLeft = Offset(x = size.width / 3F, y = size.height / 3F),
size = size / 3F
)
}
}
啥是睡眠时间表?
我们可以在 Health Connect 中读取或写入睡眠数据。睡眠数据显示为会话,可分为以下睡眠阶段:
- 清醒:用户在睡眠周期内清醒。
- 浅睡眠:用户处于浅睡眠周期。
- 深睡眠:用户处于深睡眠周期。
- REM:用户处于 REM 睡眠周期。
这些值表示用户在一定时间范围内经历的睡眠类型。SleepSessionRecord 数据类型包含两部分:
- 整个睡眠过程,涵盖整个睡眠时间。
- 睡眠过程中的各个阶段,例如浅睡眠或深睡眠。
Kotlin
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,
),
),
)
}
需要一点数学计算
在睡眠期间,我们可以在不同时刻多次处于同一阶段。我们需要计算相对于睡眠的起点和终点。
要在 Compose 中绘制矩形,我们需要 topOffset 和 size。
Kotlin
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),
)
}
}
绘制
让我们构建自定义 Canvas 来绘制睡眠过程的一个阶段,例如深度睡眠。
Kotlin
@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 {
// 画背景
drawRoundRect(
color = Color.LightGray,
topLeft = Offset(x = 0f, y = size.height / 4f),
size = size.copy(height = size.height / 2f),
cornerRadius = CornerRadius(size.height / 2f),
)
// 绘制阶段点
points.forEach { point ->
drawRect(
topLeft = point.topLeft,
size = point.size,
color = Color(0xFF673AB7),
)
}
}
}
)
}
如果我们使用之前定义的睡眠会话运行项目,我们将看到 3 个矩形:1 个灰色矩形表示背景,2 个紫色矩形表示深度睡眠阶段。
Kotlin
SleepSessionCanvas(
modifier = Modifier
.fillMaxWidth()
.height(320.dp)
.padding(16.dp),
record = record,
)
为了绘制睡眠过程的所有阶段(清醒、快速眼动、浅睡眠和深睡眠),我们需要进行一些调整,将每个阶段类型垂直绘制为列组件,办法是逐行绘制并对下一行应用一些偏移量(offset)。
Kotlin
@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) {
// 画背景
drawRoundRect(
color = Color.LightGray,
topLeft = Offset(x = 0f, y = stageHeightPx / 4),
size = size.copy(height = stageHeightPx / 2),
cornerRadius = CornerRadius(stageHeightPx / 2),
)
// 绘制阶段点
points.forEach { point ->
drawRect(
topLeft = point.topLeft,
size = point.size,
color = colors.getValue(type),
)
}
}
offset += stageHeightPx + stagesSpacingPx
}
}
}
)
}
添加文本
要在 Compose 中绘制文本,我们通常可以使用 Text 可组合项。但是,在我们的示例中,我们处于 DrawScope 中,我们可以使用 DrawScope.drawText()方法。
绘制文本与其他绘制命令略有不同。通常,我们为绘制命令提供绘制形状/图像的大小(宽度和高度)。对于文本,有几个参数可以控制渲染文本的大小,例如字体大小、字体、连字符和字母间距。我们需要使用 TextMeasurer 来获取文本的测量大小,具体取决于上述因素。
请到我的Github repo中查找完整示例代码:github.com/vitoksmile/...。
欢迎搜索并关注 公众号「稀有猿诉」 获取更多的优质文章!
保护原创,请勿转载!