Spinners are fine for generic loading. But for connectivity states like GPS, Bluetooth, or network, they often feel too abstract.
I wanted something more contextual than a spinner, so I built this small component.
Here’s a pulse indicator in Jetpack Compose: expanding rings around a central icon.
The Composable
@Composable
private fun PulseIndicator(
@DrawableRes icon: Int,
modifier: Modifier = Modifier
) {
val periodMs = 3600L
val offsetsMs = longArrayOf(0L, 1200L, 2400L)
val startNs = remember { System.nanoTime() }
var frameTimeNs by remember { mutableLongStateOf(startNs) }
LaunchedEffect(Unit) {
while (true) {
withFrameNanos { now -> frameTimeNs = now }
}
}
fun phase(offsetMs: Long): Float {
val elapsedMs = (frameTimeNs - startNs) / 1_000_000L + offsetMs
return ((elapsedMs % periodMs).toFloat() / periodMs.toFloat())
}
Box(modifier.size(80.dp), contentAlignment = Alignment.Center) {
@Composable
fun Ring(p: Float) = Box(
Modifier
.matchParentSize()
.graphicsLayer {
scaleX = 1f + 0.8f * p
scaleY = 1f + 0.8f * p
alpha = 1f - p
}
.border(1.5.dp, Color.White.copy(alpha = 0.9f), CircleShape)
)
Ring(phase(offsetsMs[0]))
Ring(phase(offsetsMs[1]))
Ring(phase(offsetsMs[2]))
Box(
Modifier
.size(80.dp)
.background(Color.White, CircleShape),
contentAlignment = Alignment.Center
) {
Image(
painter = painterResource(icon),
contentDescription = null,
modifier = Modifier.size(32.dp)
)
}
}
}
How it works
- Animation timing: runs with withFrameNanos inside LaunchedEffect.
- Phase calculation: three rings use different offsets (0, 1200, 2400 ms).
- Rendering: rings expand and fade using graphicsLayer.
- Core icon: central static anchor (e.g., a location pin).
Result: a smooth pulse effect that communicates connection status more clearly than a generic spinner.
📖 Originally published on ProAndroidDev (Medium):
Pulse Indicator in Jetpack Compose: Ready-to-Use @Composable
Top comments (0)