Connect with us

Разработка

Рисуем график сна в Compose

В этой статье приведено руководство по созданию собственного графика сна, подобного тому, который можно найти в приложении Fitbit.

Опубликовано

/

     
     

Кастомная отрисовка полезна в тех случаях, когда встроенные компоненты просто не делают то, что нужно нашему приложению. В этой статье приведено руководство по созданию собственного графика сна, подобного тому, который можно найти в приложении Fitbit.

Как рисовать в Compose?

Чтобы начать рисовать в Compose, мы можем использовать модификаторы рисования или композабл Canvas, который предоставляет нам DrawScope — декларативный stateless API для рисования фигур и контуров, не требующий от пользователей понимания базового состояния самого холста. Реализациям DrawScope также предоставляется информация о размерах, а преобразования выполняются относительно локальных размеров.

Примечание: Jetpack Compose (только для Android) и Compose Multiplatform (Desktop, Android, iOS, web) имеют схожий API для рисования. Скриншоты ниже сделаны на Desktop (macOS), но результат одинаков на всех платформах (смотрите последний скриншот).

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

Рисуем график сна в Compose

Что такое график сна?

Мы можем считывать или записывать данные сна в Health Connect. Данные сна отображаются в виде сессий и могут быть разделены на стадии :

  • Бодрствование: пользователь бодрствует в течение цикла сна.
  • Легкий сон: пользователь находится в цикле легкого сна.
  • Глубокий сон: пользователь находится в цикле глубокого сна.
  • Быстрый сон: пользователь находится в цикле REM (Rapid Eye Movement) сна.

Эти значения отражают тип сна пользователя в определенном временном диапазоне.

Тип данных SleepSessionRecord состоит из двух частей:

  1. Общая сессия, охватывающая всю продолжительность сна.
  2. Отдельные стадии во время сеанса сна, такие как легкий сон или глубокий сон.
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.

Рисуем график сна в Compose

Рисуем график сна в Compose

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),
        )
    }
}

Рисование

Давайте создадим наш кастомный холст для рисования одной из стадий сна, например, глубокого.

@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),
                    )
                }
            }
        }
    )
}

Если мы запустим проект с заданной ранее сессией сна, то увидим 3 прямоугольника: 1 серый прямоугольник для фона и 2 фиолетовых прямоугольника для стадии глубокого сна.

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

Рисуем график сна в Compose

Чтобы нарисовать все стадии сна (бодрствование, REM, легкий и глубокий), нам нужно внести несколько изменений, чтобы нарисовать каждый тип стадии как компонент Column, вертикально, рисуя линию за линией и применяя некоторое смещение для следующей линии.

@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
                    }
                }
            }
    )
}

Рисуем график сна в Compose

Отрисовка текста

Чтобы нарисовать текст в Compose, мы обычно используем композит Text. Однако в нашем примере мы находимся в DrawScope и можем использовать метод DrawScope.drawText().

Отрисовка текста работает немного иначе, чем другие команды рисования. Обычно мы задаем команде рисования размер (ширину и высоту), в котором нужно нарисовать фигуру/изображение. В случае с текстом есть несколько параметров, которые управляют размером отрисованного текста, например размер шрифта, сам шрифт, лигатура и расстояние между буквами. Нам нужно использовать TextMeasurer, чтобы получить измеренный размер текста в зависимости от вышеперечисленных факторов.

Рисуем график сна в Compose

Рисуем график сна в Compose

Полный пример находится в моем репозитории: https://github.com/vitoksmile/Sleep-timeline-graph.

Источник

Если вы нашли опечатку - выделите ее и нажмите Ctrl + Enter! Для связи с нами вы можете использовать info@apptractor.ru.
Telegram

Популярное

Сообщить об опечатке

Текст, который будет отправлен нашим редакторам: