Разработка
Рисуем график сна в 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
)
}
}
Что такое график сна?
Мы можем считывать или записывать данные сна в Health Connect. Данные сна отображаются в виде сессий и могут быть разделены на стадии :
- Бодрствование: пользователь бодрствует в течение цикла сна.
- Легкий сон: пользователь находится в цикле легкого сна.
- Глубокий сон: пользователь находится в цикле глубокого сна.
- Быстрый сон: пользователь находится в цикле REM (Rapid Eye Movement) сна.
Эти значения отражают тип сна пользователя в определенном временном диапазоне.
Тип данных SleepSessionRecord
состоит из двух частей:
- Общая сессия, охватывающая всю продолжительность сна.
- Отдельные стадии во время сеанса сна, такие как легкий сон или глубокий сон.
xxxxxxxxxx
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
.
xxxxxxxxxx
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),
)
}
}
Рисование
Давайте создадим наш кастомный холст для рисования одной из стадий сна, например, глубокого.
xxxxxxxxxx
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 фиолетовых прямоугольника для стадии глубокого сна.
xxxxxxxxxx
SleepSessionCanvas(
modifier = Modifier
.fillMaxWidth()
.height(320.dp)
.padding(16.dp),
record = record,
)
Чтобы нарисовать все стадии сна (бодрствование, REM, легкий и глубокий), нам нужно внести несколько изменений, чтобы нарисовать каждый тип стадии как компонент Column
, вертикально, рисуя линию за линией и применяя некоторое смещение для следующей линии.
xxxxxxxxxx
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, мы обычно используем композит Text
. Однако в нашем примере мы находимся в DrawScope
и можем использовать метод DrawScope.drawText()
.
Отрисовка текста работает немного иначе, чем другие команды рисования. Обычно мы задаем команде рисования размер (ширину и высоту), в котором нужно нарисовать фигуру/изображение. В случае с текстом есть несколько параметров, которые управляют размером отрисованного текста, например размер шрифта, сам шрифт, лигатура и расстояние между буквами. Нам нужно использовать TextMeasurer
, чтобы получить измеренный размер текста в зависимости от вышеперечисленных факторов.
Полный пример находится в моем репозитории: https://github.com/vitoksmile/Sleep-timeline-graph.
-
Новости2 недели назад
Видео и подкасты о мобильной разработке 2025.14
-
Видео и подкасты для разработчиков4 недели назад
Javascript для бэкенда – отличная идея: Node.js, NPM, Typescript
-
Новости4 недели назад
Видео и подкасты о мобильной разработке 2025.12
-
Разработка3 недели назад
«Давайте просто…»: системные идеи, которые звучат хорошо, но почти никогда не работают