本文讨论下如何在Jetpack Compose中实现一个进度条组件,技术点主要有四点,前三点都是androidx.compose.ui.graphics.drawscope.DrawScope.kt
提供的绘制api,第四点是协程的使用:
/**
* Draws a circle at the provided center coordinate and radius. If no center point is provided
* the center of the bounds is used.
*
* @param brush The color or fill to be applied to the circle
* @param radius The radius of the circle
* @param center The center coordinate where the circle is to be drawn
* @param alpha Opacity to be applied to the circle from 0.0f to 1.0f representing
* fully transparent to fully opaque respectively
* @param style Whether or not the circle is stroked or filled in
* @param colorFilter ColorFilter to apply to the [brush] when drawn into the destination
* @param blendMode Blending algorithm to be applied to the brush
*/
fun drawCircle(
brush: Brush,
radius: Float = size.minDimension / 2.0f,
center: Offset = this.center,
@FloatRange(from = 0.0, to = 1.0) alpha: Float = 1.0f,
style: DrawStyle = Fill,
colorFilter: ColorFilter? = null,
blendMode: BlendMode = DefaultBlendMode
)
/**
* Draw an arc scaled to fit inside the given rectangle. It starts from
* startAngle degrees around the oval up to startAngle + sweepAngle
* degrees around the oval, with zero degrees being the point on
* the right hand side of the oval that crosses the horizontal line
* that intersects the center of the rectangle and with positive
* angles going clockwise around the oval. If useCenter is true, the arc is
* closed back to the center, forming a circle sector. Otherwise, the arc is
* not closed, forming a circle segment.
*
* @param color Color to be applied to the arc
* @param topLeft Offset from the local origin of 0, 0 relative to the current translation
* @param size Dimensions of the arc to draw
* @param startAngle Starting angle in degrees. 0 represents 3 o'clock
* @param sweepAngle Size of the arc in degrees that is drawn clockwise relative to [startAngle]
* @param useCenter Flag indicating if the arc is to close the center of the bounds
* @param alpha Opacity to be applied to the arc from 0.0f to 1.0f representing
* fully transparent to fully opaque respectively
* @param style Whether or not the arc is stroked or filled in
* @param colorFilter ColorFilter to apply to the [color] when drawn into the destination
* @param blendMode Blending algorithm to be applied to the arc when it is drawn
*/
fun drawArc(
color: Color,
startAngle: Float,
sweepAngle: Float,
useCenter: Boolean,
topLeft: Offset = Offset.Zero,
size: Size = this.size.offsetSize(topLeft),
@FloatRange(from = 0.0, to = 1.0) alpha: Float = 1.0f,
style: DrawStyle = Fill,
colorFilter: ColorFilter? = null,
blendMode: BlendMode = DefaultBlendMode
)
/**
* Draws the given [Path] with the given [Color]. Whether this shape is
* filled or stroked (or both) is controlled by [DrawStyle]. If the path is
* filled, then subpaths within it are implicitly closed (see [Path.close]).
*
*
* @param path Path to draw
* @param color Color to be applied to the path
* @param alpha Opacity to be applied to the path from 0.0f to 1.0f representing
* fully transparent to fully opaque respectively
* @param style Whether or not the path is stroked or filled in
* @param colorFilter ColorFilter to apply to the [color] when drawn into the destination
* @param blendMode Blending algorithm to be applied to the path when it is drawn
*/
fun drawPath(
path: Path,
color: Color,
@FloatRange(from = 0.0, to = 1.0) alpha: Float = 1.0f,
style: DrawStyle = Fill,
colorFilter: ColorFilter? = null,
blendMode: BlendMode = DefaultBlendMode
)
- 使用协程执行动画
最终的效果如图:
源码如下:
// Act2.kt:
class Act2 : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
rootLayout()
}
}
}
@Preview(showBackground = true)
@Composable
fun rootLayout() {
var animKey: MutableState<Long> = remember { mutableStateOf(System.currentTimeMillis()) }
Box(
Modifier
.fillMaxSize()
.padding(20.dp)
) {
Spacer(modifier = Modifier.padding(top = 30.dp))
Button(onClick = {
animKey.value = System.currentTimeMillis()
}) {
Text(text = "reset", fontSize = 26.sp)
}
Spacer(modifier = Modifier.padding(top = 30.dp))
Speedometer(
Modifier
.fillMaxSize()
.align(Alignment.Center),
inputValue = 80,
animKey = animKey,
)
}
}
}
// Speedometer.kt:
@Preview(showBackground = true)
@Composable
fun Speedometer(
modifier: Modifier = Modifier.fillMaxSize(),
inputValue: Int = 80,
trackColor: Color = Color(0xFFE0E0E0),
progressColors: List<Color> = listOf(Color.Green, Color.Cyan),
innerGradient: Color = Color.Yellow,
animKey: MutableState<Long> = remember { mutableStateOf(System.currentTimeMillis()) },
) {
val viewModel: SpeedometerViewModel = androidx.lifecycle.viewmodel.compose.viewModel { SpeedometerViewModel(SpeedometerConfig(0, inputValue)) }
val config = viewModel.config.collectAsState()
val previewMode = LocalInspectionMode.current
val meterValue = getMeterValue(config.value.percentCur)
var txtTop = remember { mutableStateOf(0.dp) }
Box(modifier = modifier.size(196.dp)) {
Canvas(modifier = Modifier.fillMaxSize()) {
val sweepAngle = 240f
val fillSwipeAngle = (meterValue / 100f) * sweepAngle
val startAngle = 150f
val height = size.height
val width = size.width
val centerOffset = Offset(width / 2f, height / 2f)
// calculate the needle angle based on input value
val needleAngle = (meterValue / 100f) * sweepAngle + startAngle
val needleLength = 160f // adjust the value to control needle length
val needleBaseRadius = 10f // adjust this value to control the needle base width
val arcRadius = width / 2.3f
txtTop.value = (width / 2f + 200).toDp()
drawCircle(
Brush.radialGradient(
listOf(
innerGradient.copy(alpha = 0.2f),
Color.Transparent
)
), width / 2f
)
drawArc(
color = trackColor,
startAngle = startAngle,
sweepAngle = sweepAngle,
useCenter = false,
topLeft = Offset(centerOffset.x - arcRadius, centerOffset.y - arcRadius),
size = Size(arcRadius * 2, arcRadius * 2),
style = Stroke(width = 50f, cap = StrokeCap.Round)
)
drawArc(
brush = Brush.horizontalGradient(progressColors),
startAngle = startAngle,
sweepAngle = fillSwipeAngle,
useCenter = false,
topLeft = Offset(centerOffset.x - arcRadius, centerOffset.y - arcRadius),
size = Size(arcRadius * 2, arcRadius * 2),
style = Stroke(width = 50f, cap = StrokeCap.Round)
)
drawCircle(if (previewMode) Color.Red else Color.Magenta, 24f, centerOffset)
val needlePath = Path().apply {
// calculate the top point of the needle
val topX = centerOffset.x + needleLength * cos(Math.toRadians(needleAngle.toDouble()).toFloat())
val topY = centerOffset.y + needleLength * sin(Math.toRadians(needleAngle.toDouble()).toFloat())
// Calculate the base points of the needle
val baseLeftX = centerOffset.x + needleBaseRadius * cos(
Math.toRadians((needleAngle - 90).toDouble()).toFloat()
)
val baseLeftY = centerOffset.y + needleBaseRadius * sin(
Math.toRadians((needleAngle - 90).toDouble()).toFloat()
)
val baseRightX = centerOffset.x + needleBaseRadius * cos(
Math.toRadians((needleAngle + 90).toDouble()).toFloat()
)
val baseRightY = centerOffset.y + needleBaseRadius * sin(
Math.toRadians((needleAngle + 90).toDouble()).toFloat()
)
moveTo(topX, topY)
lineTo(baseLeftX, baseLeftY)
lineTo(baseRightX, baseRightY)
close()
}
drawPath(
color = if (previewMode) Color.Blue else Color.Magenta,
path = needlePath
)
}
Column(
modifier = Modifier
.padding(top = txtTop.value)
.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = "${config.value.percentCur} %", fontSize = 20.sp, lineHeight = 28.sp, color = Color.Red)
}
}
// execute 500ms anim
val animDur = 500
val delay = 10L
val cnt = animDur / delay
val seg = inputValue * 1f / cnt
LaunchedEffect(key1 = animKey.value) {
withContext(Dispatchers.Default) {
flow {
for (i in 1..cnt) {
delay(delay)
emit(i) // 发出数据项
}
}.collect {
viewModel.onConfigChanged(SpeedometerConfig((seg * it).toInt(), inputValue)) // 跟emit在同一子线程
}
}
}
}
private fun getMeterValue(inputPercentage: Int): Int {
return if (inputPercentage < 0) {
0
} else if (inputPercentage > 100) {
100
} else {
inputPercentage
}
}
class SpeedometerViewModel(configP: SpeedometerConfig) : ViewModel() {
private val _config: MutableStateFlow<SpeedometerConfig> = MutableStateFlow(configP)
val config: StateFlow<SpeedometerConfig> = _config.asStateFlow()
fun onConfigChanged(config: SpeedometerConfig) {
_config.update { config }
}
}
data class SpeedometerConfig(
var percentCur: Int = 0,
var percentTarget: Int = 0
)
可以看到,代码比较简短和简单。
两个drawCircle
方法就不解释了,看api的注释就能理解。
第一个drawArc
是绘制一个厚度=50像素、起始角度=150度、背景色是trackColor=0xFFE0E0E0 即灰色的一段240度的弧、
第二个drawArc
是绘制一个厚度=50像素、起始角度=150度、背景色是从Color.Green向Color.Cyan渐变效果的一段240 * 80%度的弧。
要绘制一段弧,我们需要知道三点:1绘制的矩形框架决定弧的位置和大小;2弧的起始角度startAngle;3相对起始角度的扫描角度sweepAngle。关于第一点的矩形,我们传入的topLeft + size这两个参数就能够决定一个确定的矩形,我们传入的是一个正方形,绘制进度条的弧的话,还是传入正方形比较好吧,不是正方形的弧看起来怪怪的。关于角度,我们需要知道0角度的位置就是x轴正方向的位置,这和我们初中学的几何的0度位置是一样的,但是90度是逆时针旋转90度后的位置,这和初中的几何就反着来了,初中几何90度的位置是顺时针旋转90度。
还有一个drawPath,这是绘制一个三角形,根据进度条的角度80% * 240 + 150,再利用sin cos三角函数就能确定三角形的三个点的位置,有了三个点的位置,我们可以确定一个Path对象,然后调用drawPath函数就能绘制出来这个三角形。
最后,还有一个动画的逻辑,这个是在协程中执行的,本来想着是怎么用属性动画,后来感觉协程好像比较简单,就直接在协程里做了。这个动画逻辑主要是动态修改进度条的起始角度,在500ms内分50次(delay=10ms)从0到80改变,这样在recompose时,第二个drawArc和drawPath这两个绘制的效果就会变化。需要注意的是协程LaunchedEffect参数key1=animKey.value是动画开始的时间戳,这样在500ms内每次recompose时只会执行一次协程的操作,只有在点击reset按钮重新执行动画时才修改这个key1的值。
原文链接: