Skip to content

Instantly share code, notes, and snippets.

@brkckr
Last active January 14, 2026 22:01
Show Gist options
  • Select an option

  • Save brkckr/77c7c878f79144bc8a7a792cd30bdb0a to your computer and use it in GitHub Desktop.

Select an option

Save brkckr/77c7c878f79144bc8a7a792cd30bdb0a to your computer and use it in GitHub Desktop.
a cheap copy of speedtest on android/jetpack compose
@Composable
fun Speedometer(speed: Float) {
// Text measurer for drawing numbers
val textMeasurer = rememberTextMeasurer()
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(400.dp)
) {
Canvas(modifier = Modifier.fillMaxSize()) {
val radius = min(size.width, size.height) * 0.4f // arc's radius
val needleLength = 250f
val stroke = 20.dp.toPx()
val startAngle = 150f
val sweepAngel = 240f
drawArc(
color = Color(0xFF1f2642),
startAngle = startAngle,
sweepAngle = sweepAngel,
useCenter = false,
style = Stroke(width = stroke),
topLeft = Offset(center.x - radius, center.y - radius),
size = Size(radius * 2, radius * 2)
)
drawArc(
brush = Brush.linearGradient(
colors = listOf(
Color(0xFF01a3e3),
Color(0xFF0eb3a5)
),
start = Offset(
center.x + radius * cos(Math.toRadians(150.0)).toFloat(),
center.y + radius * sin(Math.toRadians(150.0)).toFloat()
),
end = Offset(
center.x + radius * cos(Math.toRadians(150.0 + (speed / 100) * 240.0)).toFloat(),
center.y + radius * sin(Math.toRadians(150.0 + (speed / 100) * 240.0)).toFloat()
)
),
startAngle = startAngle,
sweepAngle = (speed / 100) * 240f,
useCenter = false,
style = Stroke(width = stroke),
topLeft = Offset(center.x - radius, center.y - radius),
size = Size(radius * 2, radius * 2)
)
drawArc(
brush = Brush.linearGradient(
colors = listOf(
Color(0x4001a3e3),
Color(0x1001a3e3)
),
start = Offset(
center.x + radius * cos(Math.toRadians(150.0)).toFloat(),
center.y + radius * sin(Math.toRadians(150.0)).toFloat()
),
end = Offset(
center.x + radius * cos(Math.toRadians(150.0 + (speed / 100) * 240.0)).toFloat(),
center.y + radius * sin(Math.toRadians(150.0 + (speed / 100) * 240.0)).toFloat()
)
),
startAngle = startAngle,
sweepAngle = (speed / 100) * 240f,
useCenter = false,
style = Stroke(width = stroke),
topLeft = Offset(center.x - radius + stroke, center.y - radius + stroke),
size = Size((radius - stroke) * 2, (radius - stroke) * 2)
)
val angle = 150 + (speed / 100) * 240
val rad = Math.toRadians(angle.toDouble())
val needleEnd = Offset(
center.x + needleLength * cos(rad).toFloat(),
center.y + needleLength * sin(rad).toFloat()
)
val baseWidth = 40f // Wide base
val tipWidth = 10f // Narrow tip
val perpendicularAngle = rad + Math.PI / 2 // Perpendicular to needle direction
// Calculate trapezoid points
val baseLeft = Offset(
center.x + (baseWidth / 2) * cos(perpendicularAngle).toFloat(),
center.y + (baseWidth / 2) * sin(perpendicularAngle).toFloat()
)
val baseRight = Offset(
center.x - (baseWidth / 2) * cos(perpendicularAngle).toFloat(),
center.y - (baseWidth / 2) * sin(perpendicularAngle).toFloat()
)
val tipLeft = Offset(
needleEnd.x + (tipWidth / 2) * cos(perpendicularAngle).toFloat(),
needleEnd.y + (tipWidth / 2) * sin(perpendicularAngle).toFloat()
)
val tipRight = Offset(
needleEnd.x - (tipWidth / 2) * cos(perpendicularAngle).toFloat(),
needleEnd.y - (tipWidth / 2) * sin(perpendicularAngle).toFloat()
)
// Create trapezoid path
val path = Path().apply {
moveTo(baseLeft.x, baseLeft.y)
lineTo(tipLeft.x, tipLeft.y)
lineTo(tipRight.x, tipRight.y)
lineTo(baseRight.x, baseRight.y)
close()
}
// Draw trapezoid needle
drawPath(
path = path,
brush = Brush.linearGradient(
colors = listOf(
Color.White.copy(alpha = 0.3f),
Color.White.copy(alpha = 0.5f),
Color.White
),
start = center,
end = needleEnd
)
)
// Draw only 6 numbers
val numbers = listOf(0, 20, 40, 60, 80, 100)
val currentSweepAngle = (speed / 100) * 240f // Current arc progress in degrees
for (value in numbers) {
val textAngle = 150 + (value / 100f) * 240 // Map value to arc angle
val rad = Math.toRadians(textAngle.toDouble())
val textX = center.x + radius * 0.8f * cos(rad).toFloat()
val textY = center.y + radius * 0.8f * sin(rad).toFloat()
// Calculate color based on arc progress
val numberSweepAngle = (value / 100f) * 240f // Sweep angle for this number
val alpha = if (currentSweepAngle >= numberSweepAngle) {
1.0f // Full white when arc reaches or passes the number
} else {
0.5f // Semi-transparent white if arc hasn't reached
}
// Measure and draw text
val textLayoutResult = textMeasurer.measure(
text = value.toString(),
style = TextStyle(
color = Color.White.copy(alpha = alpha),
fontSize = 14.sp,
fontWeight = FontWeight.Medium
)
)
// Draw text horizontally, no rotation
drawText(
textLayoutResult = textLayoutResult,
topLeft = Offset(
textX - textLayoutResult.size.width / 2,
textY - textLayoutResult.size.height / 2
)
)
}
// Draw download speed at the bottom of the Canvas
val downloadText = "${"%.1f".format(speed)} Mbps"
val downloadTextLayoutResult = textMeasurer.measure(
text = downloadText,
style = TextStyle(
color = Color.White,
fontSize = 24.sp,
fontWeight = FontWeight.Bold
)
)
drawText(
textLayoutResult = downloadTextLayoutResult,
topLeft = Offset(
center.x - downloadTextLayoutResult.size.width / 2,
size.height * 0.7f
)
)
}
}
}
@Composable
fun SpeedTestScreen() {
// State to track if the test is running
var isTesting by remember { mutableStateOf(false) }
// State for download speed
var downloadSpeed by remember { mutableFloatStateOf(0f) }
// Animated speed value for smooth transitions
val animatedSpeed by animateFloatAsState(
targetValue = if (isTesting) downloadSpeed else 0f,
animationSpec = tween(durationMillis = 2000)
)
Column(
modifier = Modifier
.fillMaxSize()
.background(colorResource(R.color.background))
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceEvenly
) {
// Speedometer component
Speedometer(speed = animatedSpeed)
// Start/Stop button
Button(
onClick = {
isTesting = !isTesting
downloadSpeed = if (isTesting) {
// Simulate speed test with random values
(10..100).random().toFloat()
} else {
// Reset values when test stops
0f
}
},
modifier = Modifier
.padding(16.dp)
.width(200.dp)
) {
Text(text = if (isTesting) "Stop" else "Start", fontSize = 24.sp)
}
}
}
@Composable
fun HomeScreen(navigate: (String) -> Unit) {
// state to control which animation is active
var isHeartbeatActive by remember { mutableStateOf(true) }
// heartbeat animation
val heartbeatScale = remember { Animatable(1f) }
// ripple animation
val rippleScale = remember { Animatable(1f) }
val rippleAlpha = remember { Animatable(1f) }
// manage animation sequence
LaunchedEffect(Unit) {
while (true) {
// run heartbeat animation
isHeartbeatActive = true
heartbeatScale.animateTo(
targetValue = 0.9f,
animationSpec = tween(300, easing = FastOutSlowInEasing)
)
heartbeatScale.animateTo(
targetValue = 1f,
animationSpec = tween(300, easing = FastOutSlowInEasing)
)
// run ripple animation
isHeartbeatActive = false
rippleScale.snapTo(1f)
rippleAlpha.snapTo(1f)
coroutineScope {
launch {
rippleScale.animateTo(
targetValue = 1.5f,
animationSpec = tween(1300, easing = LinearEasing)
)
}
launch {
rippleAlpha.animateTo(
targetValue = 0f,
animationSpec = tween(1300, easing = LinearEasing)
)
}
}
}
}
Box(
modifier = Modifier
.fillMaxSize()
.background(colorResource(R.color.background)),
contentAlignment = Alignment.Center
) {
// draw ripple and heartbeat animations
Canvas(modifier = Modifier.size(200.dp)) {
val baseCircleSize = size.width * 0.8f
val center = Offset(size.width / 2, size.height / 2)
// draw ripple effect only when heartbeat is inactive
if (!isHeartbeatActive) {
drawCircle(
color = Color(0xFF19ab77).copy(alpha = rippleAlpha.value),
radius = baseCircleSize * rippleScale.value / 2,
center = center,
style = Stroke(width = 8f)
)
}
// draw static circle with heartbeat effect
drawCircle(
color = Color(0xFF19ab77),
radius = (baseCircleSize / 2) * heartbeatScale.value,
center = center,
style = Stroke(width = 10f)
)
}
// GO text
Text(
text = "GO",
color = Color.White,
fontSize = 48.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier
.clickable { navigate("speedtest") }
.padding(16.dp)
)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment