使用JetPack Compose实现睡眠数据图

861 阅读8分钟

如需要转载,请注明原作者

本文目的是拆解Google Android官方Compose项目JetLagged中睡眠数据图的实现过程,涵盖绘制图形、渐变色、动画效果等关键知识点。JetLagged项目地址,文章中的代码是我自己实现过程总结,和官方的案例代码会有一些出入,请注意。

最终效果

finall.gif

知识点

  1. 如何用Path绘制图形
  2. Modifier.drawCache()的使用
  3. 如何用Brush制作渐变色
  4. 如何使用Transition为多个属性添加动画效果

前期准备

准备好需要的颜色

Color.kt文件中写入以下颜色数据:

val SleepAwake = Color(0xFFFFEAC1) // 清醒时期
val SleepRem = Color(0xFFFFDD9A) // 快速动眼睡眠
val SleepLight = Color(0xFFFFCB66) // 核心睡眠
val SleepDeep = Color(0xFFFF973C) // 深度睡眠

准备好需要的文本

在strings.xml文件中写入以下数据:

<string name="sleep_type_awake">Awake</string>
<string name="sleep_type_rem">REM</string>
<string name="sleep_type_light">Light</string>
<string name="sleep_type_deep">Deep</string>

创建一个虚拟的睡眠数据

新建一个SleepData.kt文件,创建一个睡眠阶段类型的数据:

enum class SleepType(val title: Int) {
    Awake(R.string.sleep_type_awake), // 清醒时期
    REM(R.string.sleep_type_rem), // 快速动眼睡眠
    Light(R.string.sleep_type_light), // 核心睡眠时期
    Deep(R.string.sleep_type_deep) // 深度睡眠时期
}
data class SleepDayData( // 每日睡眠数据
    val startDate: LocalDateTime,
    val sleepPeriods: List<SleepPeriod>,
    val sleepScore: Int,
) {
    val firstSleepStart: LocalDateTime by lazy {
        sleepPeriods.sortedBy(SleepPeriod::startTime).first().startTime
    }
    val lastSleepEnd: LocalDateTime by lazy {
        sleepPeriods.sortedBy(SleepPeriod::startTime).last().endTime
    }
    val totalTimeInBed: Duration by lazy {
        Duration.between(firstSleepStart, lastSleepEnd)
    }
}
data class SleepPeriod( // 每日睡眠中的不同睡眠阶段
    val startTime: LocalDateTime,
    val endTime: LocalDateTime,
    val type: SleepType,
) {

    val duration: Duration by lazy {
        Duration.between(startTime, endTime)
    }
}
data class SleepGraphData(
    val sleepDayData: List<SleepDayData>,
) {
    val earliestStartHour: Int by lazy {
        sleepDayData.minOf { it.firstSleepStart.hour }
    }
    val latestEndHour: Int by lazy {
        sleepDayData.maxOf { it.lastSleepEnd.hour }
    }
}

最后创建一个FakeSleepData:

val sleepData = SleepGraphData(
    listOf(
        SleepDayData(
            LocalDateTime.now().minusDays(7),
            listOf(
                SleepPeriod(
                    startTime = LocalDateTime.now()
                        .minusDays(7)
                        .withHour(21)
                        .withMinute(8),
                    endTime = LocalDateTime.now()
                        .minusDays(7)
                        .withHour(21)
                        .withMinute(40),
                    type = SleepType.Awake
                ),
                SleepPeriod(
                    startTime = LocalDateTime.now()
                        .minusDays(7)
                        .withHour(21)
                        .withMinute(40),
                    endTime = LocalDateTime.now()
                        .minusDays(7)
                        .withHour(22)
                        .withMinute(20),
                    type = SleepType.Light
                ),
                SleepPeriod(
                    startTime = LocalDateTime.now()
                        .minusDays(7)
                        .withHour(22)
                        .withMinute(20),
                    endTime = LocalDateTime.now()
                        .minusDays(7)
                        .withHour(22)
                        .withMinute(50),
                    type = SleepType.Deep
                ),
                SleepPeriod(
                    startTime = LocalDateTime.now()
                        .minusDays(7)
                        .withHour(22)
                        .withMinute(50),
                    endTime = LocalDateTime.now()
                        .minusDays(7)
                        .withHour(23)
                        .withMinute(30),
                    type = SleepType.REM
                ),
                SleepPeriod(
                    startTime = LocalDateTime.now()
                        .minusDays(7)
                        .withHour(23)
                        .withMinute(30),
                    endTime = LocalDateTime.now()
                        .minusDays(6)
                        .withHour(1)
                        .withMinute(10),
                    type = SleepType.Deep
                ),
                SleepPeriod(
                    startTime = LocalDateTime.now()
                        .minusDays(6)
                        .withHour(1)
                        .withMinute(10),
                    endTime = LocalDateTime.now()
                        .minusDays(6)
                        .withHour(2)
                        .withMinute(30),
                    type = SleepType.Awake
                ),
                SleepPeriod(
                    startTime = LocalDateTime.now()
                        .minusDays(6)
                        .withHour(2)
                        .withMinute(30),
                    endTime = LocalDateTime.now()
                        .minusDays(6)
                        .withHour(4)
                        .withMinute(10),
                    type = SleepType.Deep
                ),
                SleepPeriod(
                    startTime = LocalDateTime.now()
                        .minusDays(6)
                        .withHour(4)
                        .withMinute(10),
                    endTime = LocalDateTime.now()
                        .minusDays(6)
                        .withHour(5)
                        .withMinute(30),
                    type = SleepType.Awake
                )
            ),
            sleepScore = 90
        ),
    )

步骤一:绘制一个静态的睡眠数据图

从最终的睡眠数据图可以得知它的组成形状:

  1. 代表每个睡眠阶段的矩形
  2. 连接每个睡眠阶段矩形的直线

首先新建一个代表睡眠数据图的Composable,具体的内容后续会填充:

private val barHeight = 24.dp //每个睡眠阶段矩形的高度

@Composable
fun SleepRoundedBar(sleepDayData: SleepDayData) { // 静态睡眠数据图
    Spacer(modifier = Modifier
        .drawWithCache {
            val sleepGraphPath = Path()
            sleepDayData.sleepPeriods.forEach { period ->
                // 添加代表每个睡眠阶段的矩形
            }
            onDrawBehind {
                drawPath(sleepGraphPath, SolidColor(SleepLight))
            }
        }
        .height(100.dp)
        .fillMaxWidth()
    )
}

接下来的重点是处理睡眠数据,需要根据不同睡眠阶段的起始时间、结束时间,计算出矩形的宽度periodWidth,还需要根据不同的睡眠状态类型计算出矩形相对于坐标原点的偏移量(offsetStartX, offsetStartY):

@Composable
fun SleepRoundedBar1(sleepDayData: SleepDayData) { // 静态睡眠数据图
    Spacer(modifier = Modifier
        .drawWithCache {
            val sleepGraphPath = Path()
            val barHeightPx = barHeight.toPx()
            sleepDayData.sleepPeriods.forEach { period ->
                // 添加代表每个睡眠阶段的矩形
                val percentageOfTotal = period.duration.toMinutes().toFloat() / sleepDayData.totalTimeInBed.toMinutes().toFloat()
                val periodWidth = percentageOfTotal * this.size.width

                // 计算初始坐标
                val offsetStartX = Duration.between(sleepDayData.firstSleepStart, period.startTime).toMinutes().toFloat() /
                        sleepDayData.totalTimeInBed.toMinutes().toFloat() *
                        this.size.width
                val offsetStartY = when (period.type) {
                    SleepType.Awake -> 0f
                    SleepType.REM -> barHeightPx
                    SleepType.Light -> barHeightPx * 2
                    SleepType.Deep -> barHeightPx * 3
                }
                // 添加矩形
                sleepGraphPath.addRect(
                    Rect(
                        offset = Offset(offsetStartX, offsetStartY),
                        size = Size(periodWidth, barHeightPx)
                    )
                )
            }
            onDrawBehind {
		            // 将path的图形绘制出来
                drawPath(sleepGraphPath, SolidColor(SleepLight))
            }
        }
        .height(100.dp)
        .fillMaxWidth()
    )
}

然后就可以得到这样的效果:

1.jpg

接下来是绘制连接每个矩形的直线,借助PathlineTo()方法可以实现,需要注意的是,绘制直线需要从起点画到终点,起点需要通过moveTo()方法移动过去,终点则作为参数传入lineTo()中:

sleepGraphPath.lineTo(offsetStartX, offsetStartY + barHeightPx / 2)
sleepGraphPath.addRect(
    Rect(
        offset = Offset(offsetStartX, offsetStartY),
        size = Size(periodWidth, barHeightPx)
    )
)
sleepGraphPath.moveTo(offsetStartX + periodWidth, offsetStartY + barHeightPx / 2)

这里需要注意的是,添加直线和矩形的顺序不能调换,因为在添加矩形后,会重置Path的currentPoint,导致后续的线条绘制找不到正确的起点。接下来在onDrawBehind中添加绘制线条的命令即可:

val stroke = Stroke(
    width = 2.dp.toPx(),
    cap = StrokeCap.Round,
    join = StrokeJoin.Round
)

onDrawBehind {
    drawPath(sleepGraphPath, SolidColor(SleepLight))
    drawPath(sleepGraphPath, SolidColor(SleepLight), style = stroke)
}

得到的效果如下:

2.jpg

完整的代码:

private const val animationDuration = 500
private val barHeight = 24.dp //每个睡眠阶段矩形的高度

@Composable
fun SleepRoundedBar1(sleepDayData: SleepDayData) {
    Spacer(modifier = Modifier
        .drawWithCache {
            val barHeightPx = barHeight.toPx()
            val sleepGraphPath = Path()
            val stroke = Stroke(
                width = 2.dp.toPx(),
                cap = StrokeCap.Round,
                join = StrokeJoin.Round
            )
            sleepDayData.sleepPeriods.forEach { period ->
                // 添加代表每个睡眠阶段的矩形
                val percentageOfTotal = period.duration.toMinutes().toFloat() / sleepDayData.totalTimeInBed.toMinutes().toFloat()
                val periodWidth = percentageOfTotal * this.size.width

                // 计算初始坐标
                val offsetStartX = Duration.between(sleepDayData.firstSleepStart, period.startTime).toMinutes().toFloat() /
                        sleepDayData.totalTimeInBed.toMinutes().toFloat() *
                        this.size.width
                val offsetStartY = when (period.type) {
                    SleepType.Awake -> 0f
                    SleepType.REM -> barHeightPx
                    SleepType.Light -> barHeightPx * 2
                    SleepType.Deep -> barHeightPx * 3
                }
                sleepGraphPath.lineTo(offsetStartX, offsetStartY + barHeightPx / 2)
                sleepGraphPath.addRect(
                    Rect(
                        offset = Offset(offsetStartX, offsetStartY),
                        size = Size(periodWidth, barHeightPx)
                    )
                )
                sleepGraphPath.moveTo(offsetStartX + periodWidth, offsetStartY + barHeightPx / 2)
            }
            onDrawBehind {
                drawPath(sleepGraphPath, SolidColor(SleepLight))
                drawPath(sleepGraphPath, SolidColor(SleepLight), style = stroke)
            }
        }
        .height(100.dp)
        .fillMaxWidth()
    )
}

步骤二:添加渐变色

前面我们只是使用了简单的单色,如果想要更好的视觉效果,可以在drawPath()时添加自定义的渐变色Brush

val sleepGradientBarColorStops = arrayOf(
    0f to SleepAwake,
    0.33f to SleepRem,
    0.66f to SleepLight,
    1f to SleepDeep
)

val gradientBrush = Brush.verticalGradient(
    colorStops = sleepGradientBarColorStops, // 颜色的分布
    startY = 0f, // 渐变开始位置
    endY = SleepType.entries.size * barHeightPx // 渐变结束位置
)

最后在drawPath()中添加即可:

onDrawBehind {
    drawPath(sleepGraphPath, gradientBrush)
    drawPath(sleepGraphPath, gradientBrush, style = stroke)
}

得到的效果:

3.jpg

步骤二:实现点击弹出动画

在点击之后睡眠数据图之后,它会收缩或者扩张,扩张时的动画有:

  1. 不同睡眠时期的矩形会向下移动
  2. 圆角由大变小
  3. 渐变色随高度扩散
  4. 原矩形逐渐消失,新图形逐渐出现

收缩的动画和扩张的都相反,不再列举。

这些动画都和是否扩张有关,所以用一个Boolean类型的变量isExpanded表示是否扩张的状态,这里的变化的属性涉及了好几个方面,使用Transition可以实现在多个属性之间平滑过渡(例如位置、大小、透明度),并且根据一个或多个状态的变化来自动过渡,这里我们只需要一个状态决定即可。

首先创建一个状态isExpandedTransition

var isExpanded by remember { mutableStateOf(false) }
val transition = updateTransition(targetState = isExpanded, label = "expanded")

然后设置点击事件回调,更新状态:

Column(
    modifier = Modifier
        .clickable(
		        indication = null,
            interactionSource = remember { MutableInteractionSource() }
        ){
			      isExpanded = !isExpanded
        }
        .fillMaxSize()
        .padding(16.dp),
) {

}

渐出渐入效果

首先实现渐出渐入的动画效果:

private const val animationDuration = 500 // ADD

@Composable
fun MainPage(modifier: Modifier = Modifier) {
    var isExpanded by remember { mutableStateOf(false) }
    val transition = updateTransition(targetState = isExpanded, label = "expanded")

    Column(
        modifier = Modifier
            .clickable(indication = null,
                interactionSource = remember { MutableInteractionSource() }
            ) {
                isExpanded = !isExpanded
            }
            .fillMaxSize()
            .padding(16.dp),
    ) {
        Spacer(modifier = Modifier.height(100.dp))
        // ADD
        transition.AnimatedVisibility(
            enter = fadeIn(animationSpec = tween(animationDuration)) + expandVertically(
                animationSpec = tween(animationDuration)
            ),
            exit = fadeOut(animationSpec = tween(animationDuration)) + shrinkVertically(
                animationSpec = tween(animationDuration)
            ),
            content = {},
            visible = { it }
        )
    }
}

AnimatedVisibilityAnimatedContent 可用作 Transition 的扩展函数,在Transition的状态发生变化时,触发设置的渐出渐入动画。

然后修改睡眠数据图Composable函数的传入参数,再将睡眠数据和Transition传入睡眠数据图的Composable函数中:

@Composable
fun SleepRoundedBar1(
    sleepDayData: SleepDayData,
    transition: Transition<Boolean> // ADD
) {

}
SleepRoundedBar(sleepData.sleepDayData.first(), transition)

再使用transition创建不同属性的动画效果:

// 控制高度变化
val animateHeight by transition.animateDp(
    label = "height",
    transitionSpec = {
        spring(
            dampingRatio = Spring.DampingRatioLowBouncy,
            stiffness = Spring.StiffnessLow
        )
    }
) { isExpanded ->
    if (isExpanded) 100.dp else 24.dp
}

// 控制圆角等其他变化
val animationProgress by transition.animateFloat(
    label = "progress",
    transitionSpec = {
        spring(
            dampingRatio = Spring.DampingRatioLowBouncy,
            stiffness = Spring.StiffnessLow
        )
    }) { isExpanded ->
    if (isExpanded) 1f else 0f
}

扩张/收缩动画

为了使数据图的高度会随着扩张/收缩变化,将transition创建的Dp属性动画值传入到SleepRoundBar的Modifier.height()方法中:

@Composable
fun SleepRoundedBar1(
    sleepDayData: SleepDayData,
    transition: Transition<Boolean>
) {
    Spacer(modifier = Modifier
        .drawWithCache {
 
        }
        .height(animateHeight) // ADD 
        .fillMaxWidth()
    )
}

效果如下:

1.gif

但现在只是对View的高度产生了影响,Path绘制出的图案还没有跟随animateHeight变化,所以需要修改每个矩形的offsetY:

// 计算初始坐标
val offsetStartX =
    Duration
        .between(sleepDayData.firstSleepStart, period.startTime)
        .toMinutes()
        .toFloat() /
            sleepDayData.totalTimeInBed
                .toMinutes()
                .toFloat() *
            this.size.width
// ADD
val heightBySleepType = when (period.type) {
    SleepType.Awake -> 0f
    SleepType.REM -> barHeightPx
    SleepType.Light -> barHeightPx * 2
    SleepType.Deep -> barHeightPx * 3
}
val offsetStartY = lerp( // ADD
    0f,
    heightBySleepType,
    animationProgress
)

lerp()函数可以让offsetY根据animationProgress的变化,从0heightBySleepType产生一系列高度的snapshot value,lerp()它其实是个计算线性插值的函数,只不过animateProgress是一个动画属性,所以变化过程中,线性插值也会重新计算。我们来看一下代码效果:

2.gif

可以看到不仅图形的高度变化了,渐变色也改变了,因为设置渐变Brush时,我们限制过渐变效果的起点和终点,还有颜色的分布,越靠近Y轴原点的位置,颜色就会越浅。

圆角动画

分析图形可得,初始的较大圆角不是应用在每个矩形上的,只有最左边和最右边的圆角大小是较大的,所以在drawPath()之前,我们使用clipPath()限制一下绘制区域就可以创造出圆角的效果:

val roundCornerPath = Path()
roundCornerPath.addRoundRect(
    RoundRect(
        Rect(
            Offset(0f, 0f),
            Size(this.size.width, this.size.height)
        ),
        CornerRadius(10.dp.toPx())
    )
)
onDrawBehind {
    clipPath(roundCornerPath) {
        drawPath(sleepGraphPath, gradientBrush)
        drawPath(sleepGraphPath, gradientBrush, style = stroke)
    }
}

要实现圆角动画很简单,将控制圆角的值,替换成与transition创建的属性动画值有关的变量就好:

val animateCornerRadius = CornerRadius( // ADD
    lerp(
        10.dp.toPx(),
        2.dp.toPx(),
        animationProgress
    )
)
val roundCornerPath = Path()
roundCornerPath.addRoundRect(
    RoundRect(
        Rect(
            Offset(0f, 0f),
            Size(this.size.width, this.size.height)
        ),
        animateCornerRadius // ADD
    )
)
onDrawBehind {
    clipPath(roundCornerPath) {
        drawPath(sleepGraphPath, gradientBrush)
        drawPath(sleepGraphPath, gradientBrush, style = stroke)
    }
}

再来看看最终效果:

finall.gif

最终代码

@Composable
fun MainPage(modifier: Modifier = Modifier) {
    var isExpanded by remember { mutableStateOf(false) }
    val transition = updateTransition(targetState = isExpanded, label = "expanded")

    Column(
        modifier = Modifier
            .clickable(indication = null,
                interactionSource = remember { MutableInteractionSource() }
            ) {
                isExpanded = !isExpanded
            }
            .fillMaxSize()
            .padding(16.dp),
    ) {
        Spacer(modifier = Modifier.height(100.dp))
        transition.AnimatedVisibility(
            enter = fadeIn(animationSpec = tween(animationDuration)) + expandVertically(
                animationSpec = tween(animationDuration)
            ),
            exit = fadeOut(animationSpec = tween(animationDuration)) + shrinkVertically(
                animationSpec = tween(animationDuration)
            ),
            content = { },
            visible = { it }
        )
        SleepRoundedBar(sleepData.sleepDayData.first(), transition)
    }
}
private const val animationDuration = 500
private val barHeight = 24.dp //每个睡眠阶段矩形的高度

@Composable
fun SleepRoundedBar(
    sleepDayData: SleepDayData,
    transition: Transition<Boolean>
) {
    val animateHeight by transition.animateDp(
        label = "height",
        transitionSpec = {
            spring(
                dampingRatio = Spring.DampingRatioLowBouncy,
                stiffness = Spring.StiffnessLow
            )
        }
    ) { isExpanded ->
        if (isExpanded) 100.dp else 24.dp
    }

    val animationProgress by transition.animateFloat(
        label = "progress",
        transitionSpec = {
            spring(
                dampingRatio = Spring.DampingRatioLowBouncy,
                stiffness = Spring.StiffnessLow
            )
        }) { isExpanded ->
        if (isExpanded) 1f else 0f
    }

    Spacer(modifier = Modifier
        .drawWithCache {
            val barHeightPx = barHeight.toPx()
            val sleepGraphPath = Path()
            val stroke = Stroke(
                width = 2.dp.toPx(),
                cap = StrokeCap.Round,
                join = StrokeJoin.Round
            )
            val sleepGradientBarColorStops = arrayOf(
                0f to SleepAwake,
                0.33f to SleepRem,
                0.66f to SleepLight,
                1f to SleepDeep
            )
            val gradientBrush = Brush.verticalGradient(
                colorStops = sleepGradientBarColorStops,
                startY = 0f,
                endY = SleepType.entries.size * barHeightPx
            )

            sleepDayData.sleepPeriods.forEach { period ->
                // 添加代表每个睡眠阶段的矩形
                val percentageOfTotal = period.duration
                    .toMinutes()
                    .toFloat() / sleepDayData.totalTimeInBed
                    .toMinutes()
                    .toFloat()
                val periodWidth = percentageOfTotal * this.size.width

                // 计算初始坐标
                val offsetStartX =
                    Duration
                        .between(sleepDayData.firstSleepStart, period.startTime)
                        .toMinutes()
                        .toFloat() /
                            sleepDayData.totalTimeInBed
                                .toMinutes()
                                .toFloat() *
                            this.size.width
                val heightBySleepType = when (period.type) {
                    SleepType.Awake -> 0f
                    SleepType.REM -> barHeightPx
                    SleepType.Light -> barHeightPx * 2
                    SleepType.Deep -> barHeightPx * 3
                }
                val offsetStartY = lerp(
                    0f,
                    heightBySleepType,
                    animationProgress
                )

                sleepGraphPath.lineTo(offsetStartX, offsetStartY + barHeightPx / 2)
                sleepGraphPath.addRect(
                    Rect(
                        offset = Offset(offsetStartX, offsetStartY),
                        size = Size(periodWidth, barHeightPx)
                    )
                )
                sleepGraphPath.moveTo(
                    offsetStartX + periodWidth,
                    offsetStartY + barHeightPx / 2
                )
            }
            val animateCornerRadius = CornerRadius(
                lerp(
                    10.dp.toPx(),
                    2.dp.toPx(),
                    animationProgress
                )
            )
            val roundCornerPath = Path()
            roundCornerPath.addRoundRect(
                RoundRect(
                    Rect(
                        Offset(0f, 0f),
                        Size(this.size.width, this.size.height)
                    ),
                    animateCornerRadius
                )
            )
            onDrawBehind {
                clipPath(roundCornerPath) {
                    drawPath(sleepGraphPath, gradientBrush)
                    drawPath(sleepGraphPath, gradientBrush, style = stroke)
                }
            }
        }
        .height(animateHeight)
        .fillMaxWidth()
    )
}