Android Jetpack Compose实现一个带动画的进度条组件Speedometer

本文讨论下如何在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
    )
  1. 使用协程执行动画

最终的效果如图:


demo.gif

源码如下:

// 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的值。






原文链接:

Creating a Custom Gauge Speedometer in Jetpack Compose

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,039评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,223评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 161,916评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,009评论 1 291
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,030评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,011评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,934评论 3 416
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,754评论 0 271
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,202评论 1 309
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,433评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,590评论 1 346
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,321评论 5 342
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,917评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,568评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,738评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,583评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,482评论 2 352

推荐阅读更多精彩内容