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

本文討論下如何在Jetpack Compose中實現(xiàn)一個進度條組件,技術點主要有四點,前三點都是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ā)出數據項
                }
            }.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函數就能繪制出來這個三角形。

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






原文鏈接:

Creating a Custom Gauge Speedometer in Jetpack Compose

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內容

友情鏈接更多精彩內容