Разработка
Рисуем график сна в 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
состоит из двух частей:
- Общая сессия, охватывающая всю продолжительность сна.
- Отдельные стадии во время сеанса сна, такие как легкий сон или глубокий сон.
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
.
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, )
Чтобы нарисовать все стадии сна (бодрствование, 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, мы обычно используем композит Text
. Однако в нашем примере мы находимся в DrawScope
и можем использовать метод DrawScope.drawText()
.
Отрисовка текста работает немного иначе, чем другие команды рисования. Обычно мы задаем команде рисования размер (ширину и высоту), в котором нужно нарисовать фигуру/изображение. В случае с текстом есть несколько параметров, которые управляют размером отрисованного текста, например размер шрифта, сам шрифт, лигатура и расстояние между буквами. Нам нужно использовать TextMeasurer
, чтобы получить измеренный размер текста в зависимости от вышеперечисленных факторов.
Полный пример находится в моем репозитории: https://github.com/vitoksmile/Sleep-timeline-graph.
-
Видео и подкасты для разработчиков4 недели назад
SwiftUI: алхимия приложений — превращаем идеи в реальность
-
Новости4 недели назад
Видео и подкасты о мобильной разработке 2025.3
-
Магазины приложений2 недели назад
Приложение Hot Tub появится на iOS в EC
-
Разработка3 недели назад
Смешивание цветов в SwiftUI