Apple的应用程序和小部件一直是设计的典范,也给我们的"复制系列:活动应用"和"卡片应用"提供了灵感。当他们发布了新款苹果手表Ultra时,它里面深度测量小部件的设计引起了我们的兴趣,我们觉得在安卓手机上也能模仿一下!就像我们通常在安卓复制的挑战中一样,我们使用了Jetpack Compose框架。
本文将向您介绍我们是如何实现以下内容的:创建波浪效果、使水围绕文本流动,并混合颜色。无论您是初学者还是已经熟悉Jetpack Compose的人,这些内容都会对您有所帮助。
首先,我们来考虑一个最简单的问题——如何计算和实现水位的动画效果。
enum class WaterLevelState {
StartReady,
Animating,
}
接下来,我们定义了动画的持续时间和初始状态。
val waveDuration by rememberSaveable { mutableStateOf(waveDurationInMills) }
var waterLevelState by remember { mutableStateOf(WaterLevelState.StartReady) }
之后,我们需要确定水的变化方式。这样可以在屏幕上以文本形式记录进展,并绘制水位。
val waveProgress by waveProgressAsState(
timerState = waterLevelState,
timerDurationInMillis = waveDuration
)
现在,我们来仔细看一下waveProgressAsState。我们使用animatable是因为它能提供更多的控制和自定义选项。例如,我们可以为不同的状态指定不同的动画参数。
接下来,我们要计算需要在屏幕上绘制水边缘的坐标:
val waterLevel by remember(waveProgress, containerSize.height) {
derivedStateOf {
(waveProgress * containerSize.height).toInt()
}
}
所有这些初步工作完成后,我们可以开始创建真实的波浪。
模拟波浪最常见的方法是使用以一定速度水平移动的正弦图。
我们希望它看起来更加逼真,而且它必须流动到屏幕上的元素上,因此我们需要一种更复杂的方法。实现的主要思想是定义一组表示波浪高度的点。这些值被动画化以创建波浪效果。
首先,我们创建一个包含点以存储值的列表。
val points = remember(spacing, containerSize) {
derivedStateOf {
(-spacing..containerSize.width + spacing step spacing).map { x ->
PointF(x.toFloat(), waterLevel)
}
}
}
接下来,让我们来讨论正常情况下水流顺畅、没有阻碍物的情况。这时,我们只需将水位数值填入其中。其他情况我们稍后再讨论。
LevelState.PlainMoving -> {
points.value.map {
it.y = waterLevel
}
}
考虑一个动画,它将改变每个点的高度。如果对所有的点都进行动画处理,会严重耗费性能和电池。为了节省资源,我们只用了一小部分浮点数动画数值。
@Composable
fun createAnimationsAsState1(
pointsQuantity: Int,
): MutableList<State<Float>> {
val animations = remember { mutableListOf<State<Float>>() }
val random = remember { Random(System.currentTimeMillis()) }
val infiniteAnimation = rememberInfiniteTransition()
repeat(pointsQuantity / 2) {
val durationMillis = random.nextInt(2000, 6000)
animations += infiniteAnimation.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis),
repeatMode = RepeatMode.Reverse,
)
)
}
return animations
}
为了防止动画每15个点重复一次,并使波浪不相同,我们可以设置initialMultipliers
。
@Composable
fun createInitialMultipliersAsState(pointsQuantity: Int): MutableList<Float> {
val random = remember { Random(System.currentTimeMillis()) }
return remember {
mutableListOf<Float>().apply {
repeat(pointsQuantity) { this += random.nextFloat() }
}
}
}
现在要添加波浪 - 遍历所有点并计算新的高度。
points.forEachIndexed { index, pointF ->
val newIndex = index % animations.size
var waveHeight = calculateWaveHeight(
animations[newIndex].value,
initialMultipliers[index],
maxHeight
)
pointF.y = pointF.y - waveHeight
}
return points
将initialMultipliers
添加到currentSize
将减少重复数值的可能性。同时,使用线性插值将有助于平滑地改变高度。
private fun calculateWaveHeight(
currentSize: Float,
initialMultipliers: Float,
maxHeight: Float
): Float {
var waveHeightPercent = initialMultipliers + currentSize
if (waveHeightPercent > 1.0f) {
val diff = waveHeightPercent - 1.0f
waveHeightPercent = 1.0f - diff
}
return lerpF(maxHeight, 0f, waveHeightPercent)
}
现在让我们来看最有趣的部分——如何让水围绕着用户界面元素流动。
首先,我们定义了水在下降过程中的三种状态。PlainMoving
表示水平常流的状态,WaveIsComing
表示水逐渐抬升到需要展示流动效果的用户界面元素的时刻,而FlowsAround
则表示实际上水已经开始围绕着UI元素流动了。
sealed class LevelState {
object PlainMoving : LevelState()
object FlowsAround : LevelState()
object WaveIsComing: LevelState()
}
我们了解到,如果水位低于物品位置减去缓冲区,那么水位就比物品高。下图中以红色标示了该区域。
fun isAboveElement(waterLevel: Int, bufferY: Float, position: Offset) = waterLevel < position.y - bufferY
当水位与元素水平相同时,开始流动还为时过早。下图中的区域以灰色显示。
fun atElementLevel(
waterLevel: Int,
buffer: Float,
elementParams: ElementParams,
) = (waterLevel >= (elementParams.position.y - buffer)) &&
(waterLevel < (elementParams.position.y + elementParams.size.height * 0.33))
fun isWaterFalls(
waterLevel: Int,
elementParams: ElementParams,
) = waterLevel >= (elementParams.position.y + elementParams.size.height * 0.33) &&
waterLevel <= (elementParams.position.y + elementParams.size.height)
还有一个问题需要考虑 —— 如何计算水流的时间?当水位在蓝色区域时,瀑布和波浪的动画会增加。因此,我们需要确定水位达到元素高度的2/3时的时间。
@Composable
fun rememberDropWaterDuration(
elementSize: IntSize,
containerSize: IntSize,
duration: Long,
): Int {
return remember(
elementSize,
containerSize
) { (((duration * elementSize.height * 0.66) / (containerSize.height))).toInt() }
}
我们来仔细看一下元素周围的水流情况。水的流动形状是基于一个抛物线的,为了教程的简单性我们选择了一个简单的形状。我们用图片中的那些点来描述抛物线的轨迹。我们并没有把抛物线延伸到当前的水位以下(即水平的红线处)。
is LevelState.FlowsAround -> {
val point1 = PointF(
position.x,
position.y - buffer / 5
)
val point2 = point1.copy(x = position.x + elementSize.width)
val point3 = PointF(
position.x + elementSize.width / 2,
position.y - buffer
)
val p = Parabola(point1, point2, point3)
points.value.forEach {
val pr = p.calculate(it.x)
if (pr > waterLevel) {
it.y = waterLevel
} else {
it.y = pr
}
}
让我们来看看瀑布动画:我们将使用相同的抛物线,改变它的高度从初始位置开始,并使用OvershootInterpolator
实现更柔和的下落效果。
val parabolaHeightMultiplier = animateFloatAsState(
targetValue = if (levelState == LevelState.WaveIsComing) 0f else -1f,
animationSpec = tween(
durationMillis = dropWaterDuration,
easing = { OvershootInterpolator(6f).getInterpolation(it) }
)
)
在这种情况下,我们使用高度倍增动画,以便最终抛物线的高度变为0。
val point1 by remember(position, elementSize, waterLevel, parabolaHeightMultiplier) {
mutableStateOf(
PointF(
position.x,
waterLevel + (elementSize.height / 3f + buffer / 5) * parabolaHeightMultiplier.value
)
)
}
val point2 by remember(position, elementSize, waterLevel, parabolaHeightMultiplier) {
mutableStateOf(
PointF(
position.x + elementSize.width,
waterLevel + (elementSize.height / 3f + buffer / 5) * parabolaHeightMultiplier.value
)
)
}
val point3 by remember(position, elementSize, parabolaHeightMultiplier, waterLevel) {
mutableStateOf(
PointF(
position.x + elementSize.width / 2,
waterLevel + (elementSize.height / 3f + buffer) * parabolaHeightMultiplier.value
)
)
}
return produceState(
initialValue = Parabola(point1, point2, point3),
key1 = point1,
key2 = point2,
key3 = point3
) {
this.value = Parabola(point1, point2, point3)
}
此外,我们需要改变与界面元素重叠的位置的波浪大小,因为在水落下的瞬间它们会增大,然后缩小至正常尺寸。
val point1 by remember(position, elementSize, waterLevel, parabolaHeightMultiplier) {
mutableStateOf(
PointF(
position.x,
waterLevel + (elementSize.height / 3f + buffer / 5) * parabolaHeightMultiplier.value
)
)
}
val point2 by remember(position, elementSize, waterLevel, parabolaHeightMultiplier) {
mutableStateOf(
PointF(
position.x + elementSize.width,
waterLevel + (elementSize.height / 3f + buffer / 5) * parabolaHeightMultiplier.value
)
)
}
val point3 by remember(position, elementSize, parabolaHeightMultiplier, waterLevel) {
mutableStateOf(
PointF(
position.x + elementSize.width / 2,
waterLevel + (elementSize.height / 3f + buffer) * parabolaHeightMultiplier.value
)
)
}
return produceState(
initialValue = Parabola(point1, point2, point3),
key1 = point1,
key2 = point2,
key3 = point3
) {
this.value = Parabola(point1, point2, point3)
}
val elementRangeX = (position.x - bufferX)..(position.x + elementSize.width + bufferX)
points.forEach { index, pointF ->
if (levelState.value is LevelState.WaveIsComing && pointF.x in elementRangeX) {
waveHeight *= waveMultiplier
}
}
现在是将我们所学的所有内容结合起来,并添加颜色混合。
在画布上进行绘画时,有几种方式可以使用混合模式。
首先,我想到的方法是使用位图来绘制路径,并在位图画布上使用混合模式来绘制文本。这种方法使用了 Android 视图中旧的画布实现,所以我们决定采用更直接的方式——使用混合模式来进行颜色混合。首先,我们在画布上绘制了波浪。
Canvas(
modifier = Modifier
.background(Water)
.fillMaxSize()
) {
drawWaves(paths)
}
在实施过程中,我们使用drawIntoCanvas
,以便我们可以使用paint.pathEffectCornerPathEffect
来平滑波浪。
fun DrawScope.drawWaves(
paths: Paths,
) {
drawIntoCanvas {
it.drawPath(paths.pathList[1], paint.apply {
color = Blue
})
it.drawPath(paths.pathList[0], paint.apply {
color = Color.Black
alpha = 0.9f
})
}
}
为了了解文本占据的空间大小,我们在一个盒子里放置了文本元素。由于布局中的Text元素不支持混合模式,所以我们需要利用混合模式在画布上绘制文本。为此,我们使用drawWithContent
修饰器,只在画布上绘制文本,而不会绘制文本元素本身。
为了使混合模式生效,需要创建一个新的图层。我们可以使用.graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen)*
来实现这一点。无论在图形图层上配置了哪些参数,内容的渲染都会首先渲染到一个离屏缓冲区,然后再绘制到目标位置上。
*(这是对我们之前实现的更新,之前我们使用了.graphicsLayer(alpha = 0.99f)
的技巧,但在评论中,@romainguy
帮助我们找到了更清晰的解决方案)。
Box(
modifier = modifier
.graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen)
.drawWithContent {
drawTextWithBlendMode(
mask = paths.pathList[0],
textStyle = textStyle,
unitTextStyle = unitTextStyle,
textOffset = textOffset,
text = text,
unitTextOffset = unitTextProgress,
textMeasurer = textMeasurer,
)
}
) {
Text(
modifier = content().modifier
.align(content().align)
.onGloballyPositioned {
elementParams.position = it.positionInParent()
elementParams.size = it.size
},
text = "46FT",
style = content().textStyle
)
}
首先我们绘制文本,然后绘制一条波浪,用作蒙版。这是有关可供开发人员使用的不同混合模式的官方文档。
https://developer.android.com/reference/kotlin/androidx/compose/ui/graphics/BlendMode#SrcIn()
fun DrawScope.drawTextWithBlendMode(
mask: Path,
textMeasurer: TextMeasurer,
textStyle: TextStyle,
text: String,
textOffset: Offset,
unitTextOffset: Offset,
unitTextStyle: TextStyle,
) {
drawText(
textMeasurer = textMeasurer,
topLeft = textOffset,
text = text,
style = textStyle,
)
drawText(
textMeasurer = textMeasurer,
topLeft = unitTextOffset,
text = "FT",
style = unitTextStyle,
)
drawPath(
path = mask,
color = Water,
blendMode = BlendMode.SrcIn
)
}
这个实现事实上相当复杂,但这是可以预料的,因为原始素材本身就很复杂。幸运的是,我们能够充分利用原生的Compose工具进行大量操作。你还可以根据需要调整参数,以获得更吸引人的水效果,但我们决定停留在这个概念验证阶段。像往常一样,完整的实现可以在存储库中找到。如果你喜欢这个教程,你还可以在我们的个人资料中找到更多有趣的内容,或者查看如何在Jetpack Compose中复制一个酷炫的dribbble
音频应用。