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.
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 } ) } } } }
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 ) )
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 }
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") } } }
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
coerceInfunction ensures the score never goes below 0 or above 100, preventing broken UI. - Adjust the
tweenparameters 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'sPixelCopyAPI.
内容的提问来源于stack exchange,提问作者Harish Padmanabh

