You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Jetpack Compose 自定义UI:带动画的分数速度表组件实现求助

Got it, let's build this custom speedometer component step by step using Jetpack Compose! I'll break down each requirement and provide working code snippets you can adapt directly.


1. Core Speedometer UI Structure

First, we'll create a base layout using Compose's Canvas to draw the dial, ticks, and static elements. We'll map the input score (0-100) to a 0° to 180° angle range for a classic half-circle speedometer:

@Composable
fun Speedometer(
    score: Float,
    modifier: Modifier = Modifier
) {
    val clampedScore = score.coerceIn(0f, 100f) // Keep score within valid bounds
    val targetAngle = (clampedScore / 100) * 180f
    val currentAngle by remember { mutableStateOf(targetAngle) } // We'll add animation next

    Box(
        modifier = modifier.size(300.dp),
        contentAlignment = Alignment.Center
    ) {
        Canvas(modifier = Modifier.fillMaxSize()) {
            val center = Offset(size.width / 2, size.height / 2)
            val radius = size.minDimension / 2 * 0.8f

            // Draw outer dial background
            drawArc(
                color = Color.Gray.copy(alpha = 0.3f),
                startAngle = 180f,
                sweepAngle = 180f,
                useCenter = false,
                style = Stroke(width = 20.dp.toPx()),
                topLeft = Offset(center.x - radius, center.y - radius),
                size = Size(radius * 2, radius * 2)
            )

            // Draw tick marks (for visual clarity)
            repeat(11) { tick ->
                val tickAngle = 180f - (tick * 18f) // Map 0-10 ticks to 180°-0°
                val tickLength = if (tick % 2 == 0) 15.dp.toPx() else 8.dp.toPx()
                val startOffset = Offset(
                    center.x + radius * cos(tickAngle * Math.PI / 180).toFloat(),
                    center.y + radius * sin(tickAngle * Math.PI / 180).toFloat()
                )
                val endOffset = Offset(
                    center.x + (radius - tickLength) * cos(tickAngle * Math.PI / 180).toFloat(),
                    center.y + (radius - tickLength) * sin(tickAngle * Math.PI / 180).toFloat()
                )
                drawLine(
                    color = Color.White,
                    start = startOffset,
                    end = endOffset,
                    strokeWidth = 3.dp.toPx()
                )
            }

            // Draw pointer
            drawLine(
                color = Color.Black,
                start = center,
                end = Offset(
                    center.x + (radius * 0.7f) * cos((180 - currentAngle) * Math.PI / 180).toFloat(),
                    center.y + (radius * 0.7f) * sin((180 - currentAngle) * Math.PI / 180).toFloat()
                ),
                strokeWidth = 8.dp.toPx(),
                cap = StrokeCap.Round
            )

            // Draw center score text
            drawContext.canvas.nativeCanvas.apply {
                drawText(
                    "${clampedScore.toInt()}",
                    center.x,
                    center.y,
                    android.graphics.Paint().apply {
                        textSize = 48.dp.toPx()
                        color = Color.Black.toArgb()
                        textAlign = android.graphics.Paint.Align.CENTER
                        textBaseline = android.graphics.Paint.FontMetrics.ASCENT
                    }
                )
            }
        }
    }
}
2. Add Animated Pointer Transition

Replace the static mutableStateOf with animateFloatAsState to handle smooth animation from 0 to the target score:

// Replace the currentAngle line with this:
val currentAngle by animateFloatAsState(
    targetValue = targetAngle,
    animationSpec = tween(
        durationMillis = 1500, // Adjust animation speed here
        easing = FastOutSlowInEasing
    )
)
3. Dynamic Background Color

Update the outer Box to use a background color that changes based on the score:

// Inside the Speedometer composable, add this before the Canvas:
val backgroundColor = if (clampedScore < 50f) Color.Red.copy(alpha = 0.2f) else Color.Green.copy(alpha = 0.2f)

// Update the Box modifier:
Box(
    modifier = modifier.size(300.dp).background(backgroundColor, shape = CircleShape),
    contentAlignment = Alignment.Center
) {
    // ... rest of the Canvas code
}
4. Social Media Sharing Feature

Add a share button that uses Android's Intent.ACTION_SEND to let users share their score to any app:

@Composable
fun SpeedometerWithShare(
    score: Float,
    modifier: Modifier = Modifier
) {
    Column(
        modifier = modifier,
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.spacedBy(16.dp)
    ) {
        Speedometer(score = score)

        val context = LocalContext.current
        Button(onClick = {
            val shareIntent = Intent(Intent.ACTION_SEND).apply {
                type = "text/plain"
                putExtra(Intent.EXTRA_TEXT, "Check out my speedometer score: ${score.toInt()}! 🎯")
            }
            context.startActivity(Intent.createChooser(shareIntent, "Share your score"))
        }) {
            Text("Share Score")
        }
    }
}
Full Working Example

Here's the complete code combining all features:

import android.content.Intent
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Size
import androidx.compose.foundation.layout.size
import androidx.compose.material.Button
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LocalContext
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.unit.dp
import kotlin.math.cos
import kotlin.math.sin

@Composable
fun SpeedometerWithShare(
    score: Float,
    modifier: Modifier = Modifier
) {
    Column(
        modifier = modifier,
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.spacedBy(16.dp)
    ) {
        Speedometer(score = score)

        val context = LocalContext.current
        Button(onClick = {
            val shareIntent = Intent(Intent.ACTION_SEND).apply {
                type = "text/plain"
                putExtra(Intent.EXTRA_TEXT, "My speedometer score: ${score.toInt()}! 🚀")
            }
            context.startActivity(Intent.createChooser(shareIntent, "Share your score"))
        }) {
            Text("Share Score")
        }
    }
}

@Composable
fun Speedometer(
    score: Float,
    modifier: Modifier = Modifier
) {
    val clampedScore = score.coerceIn(0f, 100f)
    val targetAngle = (clampedScore / 100) * 180f

    val currentAngle by animateFloatAsState(
        targetValue = targetAngle,
        animationSpec = tween(
            durationMillis = 1500,
            easing = FastOutSlowInEasing
        )
    )

    val backgroundColor = if (clampedScore < 50f) Color.Red.copy(alpha = 0.2f) else Color.Green.copy(alpha = 0.2f)

    Box(
        modifier = modifier.size(300.dp).background(backgroundColor, shape = androidx.compose.foundation.shape.CircleShape),
        contentAlignment = Alignment.Center
    ) {
        Canvas(modifier = Modifier.fillMaxSize()) {
            val center = Offset(size.width / 2, size.height / 2)
            val radius = size.minDimension / 2 * 0.8f

            drawArc(
                color = Color.Gray.copy(alpha = 0.3f),
                startAngle = 180f,
                sweepAngle = 180f,
                useCenter = false,
                style = Stroke(width = 20.dp.toPx()),
                topLeft = Offset(center.x - radius, center.y - radius),
                size = Size(radius * 2, radius * 2)
            )

            repeat(11) { tick ->
                val tickAngle = 180f - (tick * 18f)
                val tickLength = if (tick % 2 == 0) 15.dp.toPx() else 8.dp.toPx()
                val startOffset = Offset(
                    center.x + radius * cos(tickAngle * Math.PI / 180).toFloat(),
                    center.y + radius * sin(tickAngle * Math.PI / 180).toFloat()
                )
                val endOffset = Offset(
                    center.x + (radius - tickLength) * cos(tickAngle * Math.PI / 180).toFloat(),
                    center.y + (radius - tickLength) * sin(tickAngle * Math.PI / 180).toFloat()
                )
                drawLine(
                    color = Color.White,
                    start = startOffset,
                    end = endOffset,
                    strokeWidth = 3.dp.toPx()
                )
            }

            drawLine(
                color = Color.Black,
                start = center,
                end = Offset(
                    center.x + (radius * 0.7f) * cos((180 - currentAngle) * Math.PI / 180).toFloat(),
                    center.y + (radius * 0.7f) * sin((180 - currentAngle) * Math.PI / 180).toFloat()
                ),
                strokeWidth = 8.dp.toPx(),
                cap = StrokeCap.Round
            )

            drawContext.canvas.nativeCanvas.apply {
                drawText(
                    "${clampedScore.toInt()}",
                    center.x,
                    center.y,
                    android.graphics.Paint().apply {
                        textSize = 48.dp.toPx()
                        color = Color.Black.toArgb()
                        textAlign = android.graphics.Paint.Align.CENTER
                        textBaseline = android.graphics.Paint.FontMetrics.ASCENT
                    }
                )
            }
        }
    }
}

// Usage example in your screen:
@Composable
fun ScoreScreen() {
    val score = 75f // Replace with your actual score value
    SpeedometerWithShare(score = score, modifier = Modifier.padding(24.dp))
}

Quick Notes:

  • The coerceIn function ensures the score never goes below 0 or above 100, preventing broken UI.
  • Adjust the tween parameters to make the pointer animation faster or slower.
  • If you want to share an image of the speedometer instead of text, you can capture the composable as a bitmap using View.drawToBitmap() or Compose's PixelCopy API.

内容的提问来源于stack exchange,提问作者Harish Padmanabh

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.04.28 17:47:28