这两天刷贴吧的时候注意到首页列表下拉刷新时的加载控件,感觉挺有意思.
就想着"看着真不错,我也要做一个!"
正好练习下 Compose 的自定义 View .
说干就干,首先观察下贴吧加载控件的特点吧
1. 运动轨迹 : 两个图形横向来回移动 ( 贴吧是对话框形状,我这就简化了用的圆球 )
2. 大小变化 : 两个图形的缩放最大最小值是相同的,并且在同时移动到两侧的时候是相同的,移动到中间则是一大一小
3. 视觉效果 : 两图形在移动的时候,其重合的部位有特殊视觉效果
对于运动轨迹和大小变化,我的思路是创建两个循环动画来实现.
至于视觉效果,看了下 DrawScope.drawCircle() 函数的注释文档,
发现里面有个 blendMode 参数可以实现这个需求
那么,我们为 DrawScope 写一个绘制圆球的方法
/**
* @param color 圆球颜色
* @param offsetX X轴偏移量
* @param ballRadiusPx 圆球半径的像素值
* @param scale 缩放比例
* @param blendMode 混合模式
*/
private fun DrawScope.drawBall(
color: Color,
offsetX: Float,
ballRadiusPx: Float,
scale: Float,
blendMode: BlendMode
) {
drawCircle(
color = color,
center = Offset(offsetX * density, ballRadiusPx),
radius = ballRadiusPx * scale,
blendMode = blendMode
)
}
接着为运动轨迹和大小变化分别创建两个动画
计划是平移动画走完了再往回走,如此循环
缩放动画是按照顺序(变大->变小->变小->变大)走完了重走,如此循环
所以持续时间方面,缩放动画一个周期是 millis 长度,移动动画一个周期则是 millis/2 长度
val millis = 1600
val infiniteTransition = rememberInfiniteTransition(label = "")
//缩放动画
val scaleAnimate by infiniteTransition.animateFloat(
initialValue = 0F,
targetValue = 2F,
animationSpec = infiniteRepeatable(
animation = tween(millis, easing = LinearEasing),
repeatMode = RepeatMode.Restart
), label = ""
)
//调整 progress 范围为 [-1,1] , 方便后续计算 scale 数值
val progress = scaleAnimate - 1F
//移动动画
val translateAnimate by infiniteTransition.animateFloat(
initialValue = 0F,
targetValue = 1F,
animationSpec = infiniteRepeatable(
animation = tween(millis / 2, easing = LinearEasing),
repeatMode = RepeatMode.Reverse
), label = ""
)
动画写好了,现在我们通过计算来获取缩放比例以及偏移量
球1球2共用一个缩放动画,但它们的变化顺序是不一样的,
分别是
变大->变小->变小->变大
变小->变大->变大->变小
于是我写了两个 getPercent 方法来计算其当前缩放比例
val ballRadius = 100F
val maxScaleValue = 1F
val midScaleValue = maxScaleValue - scaleRangeStep
val minScaleValue = maxScaleValue - scaleRangeStep * 2
val scale1 = getPercent1(progress, minScaleValue, midScaleValue, maxScaleValue)
val scale2 = getPercent2(progress, minScaleValue, midScaleValue, maxScaleValue)
同理,其位移方向是相反的
val translateFactor = ballRadius * 2
val offsetX1 = translateFactor * translateAnimate
val offsetX2 = translateFactor * translateAnimate * -1
缩放比例以及偏移量计算好了,接下来创建画布进行绘制就 OK 啦
为了达成两圆球一前一后来回运动的效果,
分别在 progress > 0 与 progress < 0 的时候绘制不同层级的球1来改变两球的前后关系(低版本某些blendMode不生效,会直接覆盖绘制)
Box(modifier = Modifier.size((ballRadius * 4).dp, (ballRadius * 2).dp)) {
Box(
modifier = Modifier
.width((ballRadius * 4).dp)
.height((ballRadius * 2).dp)
.clip(RectangleShape)
.align(Alignment.Center)
) {
Canvas(modifier = Modifier.fillMaxSize()) {
if (progress > 0) {
drawBall(color1, (ballRadius + offsetX1), ballRadiusPx, scale1, blendMode)
}
drawBall(color2, (3 * ballRadius + offsetX2), ballRadiusPx, scale2, blendMode)
if (progress < 0) {
drawBall(color1, (ballRadius + offsetX1), ballRadiusPx, scale1, blendMode)
}
}
}
}
最后附上效果图与最终代码
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
/**
* @param baseLength 基准长度,用于计算圆形的半径,组件的宽高
* @param scaleRangeStep 用于计算圆球的缩放程度
* @param color1 球1的颜色
* @param color2 球2的颜色
*/
@Composable
fun DoubleBallLoop(
baseLength: Float,
scaleRangeStep: Float,
color1: Color,
color2: Color
) {
val millis = 1600
val infiniteTransition = rememberInfiniteTransition(label = "")
val scaleAnimate by infiniteTransition.animateFloat(
initialValue = 0F,
targetValue = 2F,
animationSpec = infiniteRepeatable(
animation = tween(millis, easing = LinearEasing),
repeatMode = RepeatMode.Restart
), label = ""
)
val translateAnimate by infiniteTransition.animateFloat(
initialValue = 0F,
targetValue = 1F,
animationSpec = infiniteRepeatable(
animation = tween(millis / 2, easing = LinearEasing),
repeatMode = RepeatMode.Reverse
), label = ""
)
//调整 progress 范围为 [-1,1]
val progress = scaleAnimate - 1F
val ballRadius = baseLength / 2
val translateFactor = ballRadius * 2
val offsetX1 = translateFactor * translateAnimate
val offsetX2 = translateFactor * translateAnimate * -1
val maxScaleValue = 1F
val midScaleValue = maxScaleValue - scaleRangeStep
val minScaleValue = maxScaleValue - scaleRangeStep * 2
val scale1 = getPercent1(progress, minScaleValue, midScaleValue, maxScaleValue)
val scale2 = getPercent2(progress, minScaleValue, midScaleValue, maxScaleValue)
val blendMode = BlendMode.Multiply
val density = LocalDensity.current.density
val ballRadiusPx = ballRadius * density
Box(modifier = Modifier.size((baseLength * 2).dp, baseLength.dp)) {
Box(
modifier = Modifier
.width((ballRadius * 4).dp)
.height((ballRadius * 2).dp)
.clip(RectangleShape)
.align(Alignment.Center)
) {
Canvas(modifier = Modifier.fillMaxSize()) {
if (progress >= 0) {
drawBall(color1, (ballRadius + offsetX1), ballRadiusPx, scale1, blendMode)
}
drawBall(color2, (3 * ballRadius + offsetX2), ballRadiusPx, scale2, blendMode)
if (progress < 0) {
drawBall(color1, (ballRadius + offsetX1), ballRadiusPx, scale1, blendMode)
}
}
}
}
}
private fun DrawScope.drawBall(
color: Color,
offsetX: Float,
ballRadiusPx: Float,
scale: Float,
blendMode: BlendMode
) {
drawCircle(
color = color,
center = Offset(offsetX * density, ballRadiusPx),
radius = ballRadiusPx * scale,
blendMode = blendMode
)
}
private fun getPercent1(progress: Float, minValue: Float, midValue: Float, maxValue: Float): Float {
val gapMinMid = (midValue - minValue)
val gapMidMax = (maxValue - midValue)
return when (progress) {
in -1F..-0.6F -> midValue + (progress + 1F) / 0.4F * gapMidMax // [-1, -0.6] 百分比均匀增大到maxValue
in -0.6F..-0.4F -> maxValue // [-0.6, -0.4] 百分比不变,固定为maxValue
in -0.4F..0F -> maxValue - (progress + 0.4F) / 0.4F * gapMidMax // [-0.4, 0] 百分比均匀减小到midValue
in 0F..0.4F -> midValue - progress / 0.4F * gapMinMid // [0, 0.4] 百分比均匀减小到minValue
in 0.4F..0.6F -> minValue // [0.4, 0.6] 百分比不变,固定为maxValue
in 0.6F..1F -> minValue + (progress - 0.6F) / 0.4F * gapMinMid // [0.6, 1] 百分比均匀增大到midValue
else -> 0.6F // 超出范围,默认返回0.6
}
}
private fun getPercent2(progress: Float, minValue: Float, midValue: Float, maxValue: Float): Float {
val gapMinMid = (midValue - minValue)
val gapMidMax = (maxValue - midValue)
return when (progress) {
in -1F..-0.6F -> midValue - (progress + 1F) / 0.4F * gapMinMid // [-1, -0.6] 百分比均匀减小到minValue
in -0.6F..-0.4F -> minValue // [-0.6, -0.4] 百分比不变,固定为minValue
in -0.4F..0F -> minValue + (progress + 0.4F) / 0.4F * gapMinMid // [-0.4, 0] 百分比均匀增大到midValue
in 0F..0.4F -> midValue + progress / 0.4F * gapMidMax // [0, 0.4] 百分比均匀增大到maxValue
in 0.4F..0.6F -> maxValue // [0.4, 0.6] 百分比不变,固定为maxValue
in 0.6F..1F -> maxValue - (progress - 0.6F) / 0.4F * gapMidMax // [0.6, 1] 百分比均匀减小到midValue
else -> 0.6F // 超出范围,默认返回0.6
}
}
如果有可以优化改进的地方欢迎指出~
更新一下,使用正弦函数可以更简单地计算缩放大小。
val scale1 = 0.8F + getScaleOffset(progress, 0.2F)
val scale2 = 0.8F - getScaleOffset(progress, 0.2F)
private fun getScaleOffset(progress: Float, step: Float): Float {
require(step >= -1 && step <= 1) { throw Exception("step must in [-1,1]") }
return sin(progress * PI).toFloat() * step
}