Android Jetpack Compose實現(xiàn)一個帶動畫的進度條組件Speedometer

本文討論下如何在Jetpack Compose中實現(xiàn)一個進度條組件,技術(shù)點主要有四點,前三點都是androidx.compose.ui.graphics.drawscope.DrawScope.kt提供的繪制api段化,第四點是協(xié)程的使用:

    /**
     * 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. 使用協(xié)程執(zhí)行動畫

最終的效果如圖:


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) // 發(fā)出數(shù)據(jù)項
                }
            }.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兰绣。關(guān)于第一點的矩形,我們傳入的topLeft + size這兩個參數(shù)就能夠決定一個確定的矩形编振,我們傳入的是一個正方形缀辩,繪制進度條的弧的話,還是傳入正方形比較好吧踪央,不是正方形的弧看起來怪怪的臀玄。關(guān)于角度,我們需要知道0角度的位置就是x軸正方向的位置畅蹂,這和我們初中學(xué)的幾何的0度位置是一樣的健无,但是90度是逆時針旋轉(zhuǎn)90度后的位置,這和初中的幾何就反著來了液斜,初中幾何90度的位置是順時針旋轉(zhuǎn)90度累贤。

還有一個drawPath响逢,這是繪制一個三角形老赤,根據(jù)進度條的角度80% * 240 + 150侧馅,再利用sin cos三角函數(shù)就能確定三角形的三個點的位置惶岭,有了三個點的位置,我們可以確定一個Path對象渗磅,然后調(diào)用drawPath函數(shù)就能繪制出來這個三角形嚷硫。

最后,還有一個動畫的邏輯始鱼,這個是在協(xié)程中執(zhí)行的仔掸,本來想著是怎么用屬性動畫,后來感覺協(xié)程好像比較簡單风响,就直接在協(xié)程里做了嘉汰。這個動畫邏輯主要是動態(tài)修改進度條的起始角度丹禀,在500ms內(nèi)分50次(delay=10ms)從0到80改變状勤,這樣在recompose時,第二個drawArc和drawPath這兩個繪制的效果就會變化双泪。需要注意的是協(xié)程LaunchedEffect參數(shù)key1=animKey.value是動畫開始的時間戳持搜,這樣在500ms內(nèi)每次recompose時只會執(zhí)行一次協(xié)程的操作,只有在點擊reset按鈕重新執(zhí)行動畫時才修改這個key1的值焙矛。






原文鏈接:

Creating a Custom Gauge Speedometer in Jetpack Compose

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末葫盼,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子村斟,更是在濱河造成了極大的恐慌贫导,老刑警劉巖,帶你破解...
    沈念sama閱讀 216,402評論 6 499
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件蟆盹,死亡現(xiàn)場離奇詭異孩灯,居然都是意外死亡,警方通過查閱死者的電腦和手機逾滥,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,377評論 3 392
  • 文/潘曉璐 我一進店門峰档,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人寨昙,你說我怎么就攤上這事讥巡。” “怎么了舔哪?”我有些...
    開封第一講書人閱讀 162,483評論 0 353
  • 文/不壞的土叔 我叫張陵欢顷,是天一觀的道長。 經(jīng)常有香客問我捉蚤,道長吱涉,這世上最難降的妖魔是什么刹泄? 我笑而不...
    開封第一講書人閱讀 58,165評論 1 292
  • 正文 為了忘掉前任,我火速辦了婚禮怎爵,結(jié)果婚禮上特石,老公的妹妹穿的比我還像新娘。我一直安慰自己鳖链,他們只是感情好姆蘸,可當(dāng)我...
    茶點故事閱讀 67,176評論 6 388
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著芙委,像睡著了一般逞敷。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上灌侣,一...
    開封第一講書人閱讀 51,146評論 1 297
  • 那天推捐,我揣著相機與錄音,去河邊找鬼侧啼。 笑死牛柒,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的痊乾。 我是一名探鬼主播皮壁,決...
    沈念sama閱讀 40,032評論 3 417
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼哪审!你這毒婦竟也來了蛾魄?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 38,896評論 0 274
  • 序言:老撾萬榮一對情侶失蹤湿滓,失蹤者是張志新(化名)和其女友劉穎滴须,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體叽奥,經(jīng)...
    沈念sama閱讀 45,311評論 1 310
  • 正文 獨居荒郊野嶺守林人離奇死亡扔水,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,536評論 2 332
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了而线。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片铭污。...
    茶點故事閱讀 39,696評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖膀篮,靈堂內(nèi)的尸體忽然破棺而出嘹狞,到底是詐尸還是另有隱情,我是刑警寧澤誓竿,帶...
    沈念sama閱讀 35,413評論 5 343
  • 正文 年R本政府宣布磅网,位于F島的核電站,受9級特大地震影響筷屡,放射性物質(zhì)發(fā)生泄漏涧偷。R本人自食惡果不足惜簸喂,卻給世界環(huán)境...
    茶點故事閱讀 41,008評論 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望燎潮。 院中可真熱鬧喻鳄,春花似錦、人聲如沸确封。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,659評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽爪喘。三九已至颜曾,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間秉剑,已是汗流浹背泛豪。 一陣腳步聲響...
    開封第一講書人閱讀 32,815評論 1 269
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留侦鹏,地道東北人诡曙。 一個月前我還...
    沈念sama閱讀 47,698評論 2 368
  • 正文 我出身青樓,卻偏偏與公主長得像种柑,于是被迫代替她去往敵國和親岗仑。 傳聞我的和親對象是個殘疾皇子匹耕,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 44,592評論 2 353

推薦閱讀更多精彩內(nèi)容