实战:在Compose中绘制睡眠时间线

本文译自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 数据类型包含两部分:

  1. 整个睡眠过程,涵盖整个睡眠时间。
  2. 睡眠过程中的各个阶段,例如浅睡眠或深睡眠。
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/...

欢迎搜索并关注 公众号「稀有猿诉」 获取更多的优质文章!

保护原创,请勿转载!

相关推荐
VincentStory4 小时前
分享一个项目中遇到的一个算法题
android·算法
yechaoa6 小时前
【揭秘大厂】技术专项落地全流程
android·前端·后端
Cachel wood7 小时前
Mysql相关知识:存储引擎、sql执行流程、索引失效
android·人工智能·sql·mysql·算法·前端框架·ab测试
每次的天空8 小时前
Android第四次面试总结(基础算法篇)
android·算法·面试
王景程8 小时前
Android Zygote的进程机制
android·github·模块测试·zygote
thinkMoreAndDoMore8 小时前
Android Audio基础(54)——数字音频接口 I2S、PCM(TDM) 、PDM
android·嵌入式硬件·pcm
江太翁9 小时前
Android Composable 与 View 的联系和区别
android
诸葛冰箱9 小时前
安卓apk加固后,Android11+无法安装
android·加固·android 11+
lynn8570_blog11 小时前
android 后台下载任务,断点续传
android