实战:在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/...

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

保护原创,请勿转载!

相关推荐
千里马学框架1 天前
一起学 Android 14:ShellTransition 屏幕旋转过程深度剖析
android·智能手机·性能优化·framework·性能·屏幕旋转·rotation
美狐美颜SDK开放平台1 天前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
AFinalStone1 天前
Android7 SystemUI源码解析(七)Keyguard锁屏模块深度解析
android·systemui
致远ccc1 天前
Google Play 上架前如何测试 App?多国家 Android 环境测试
android·app测试·googleplay·多国家应用测试
ttyyttemo1 天前
Kotlin 协程中的 Job 结构化并发与取消
android
sun0077001 天前
tbox 4g/5g切换,导致wan ip 改变,导致车机旧网络不可用。需要重启车机才行
android
ai2work1 天前
ch23 综合复刻:从零做一个最小可用版本(capstone)
kotlin
其实防守也摸鱼1 天前
内网穿透与反向代理:原理、工具与实战指南
android·大数据·运维·安全·网络安全·自动化·渗透
ai2work1 天前
ch21 签名、校验与发版
kotlin
AFinalStone2 天前
Android7 SystemUI 源码解析(四)NavigationBar 导航栏与 SystemBars
android·systemui