What You Will Build
A full-screen particle emitter where 50 glowing circles drift upward against a dark background, wrapping around when they reach the top. This effect creates ambient, living backgrounds for splash screens, music players, or atmospheric app experiences.
Why Canvas-Based Particles Matter
Composable-per-particle systems hit performance walls above 30-40 particles because each triggers recomposition. Using a single Canvas composable with drawCircle calls is dramatically faster because it bypasses the composition tree entirely. This is the same approach game engines use.
Key Compose Concepts
- Canvas composable for efficient bulk drawing without per-element recomposition.
- LaunchedEffect with delay loop for 60fps animation ticks.
- Data class mutation pattern for updating particle positions each frame.
Step 1: Define the Particle Data Class
data class Particle(
var x: Float,
var y: Float,
var vx: Float, // horizontal velocity
var vy: Float, // vertical velocity (negative = upward)
var alpha: Float, // transparency
var radius: Float // circle size
)
Step 2: Initialize Particles with Random Properties
val particles = remember {
(1..50).map {
Particle(
x = (0..1000).random().toFloat(),
y = (0..2000).random().toFloat(),
vx = (-2..2).random().toFloat(),
vy = (-3..-1).random().toFloat(),
alpha = (30..100).random() / 100f,
radius = (2..8).random().toFloat()
)
}.toMutableList()
}
Step 3: Animate with a Frame Loop
A LaunchedEffect runs an infinite loop with 16ms delay (approximately 60fps). Each tick updates every particle position and wraps particles that fly off the top:
var tick by remember { mutableIntStateOf(0) }
LaunchedEffect(Unit) {
while (true) {
delay(16)
tick++
particles.forEachIndexed { i, p ->
particles[i] = p.copy(
x = p.x + p.vx,
y = if (p.y + p.vy < 0) 2000f
else p.y + p.vy
)
}
}
}
Step 4: Draw on Canvas
Box(
modifier = Modifier
.fillMaxSize()
.background(Color(0xFF0A0A1A))
) {
Canvas(Modifier.fillMaxSize()) {
particles.forEach { p ->
drawCircle(
color = Color(0xFF6C63FF).copy(alpha = p.alpha),
radius = p.radius,
center = Offset(
p.x % size.width,
p.y % size.height
)
)
}
}
}
Tips and Pitfalls
- Modulo wrapping (
% size.width) ensures particles that drift off-screen horizontally wrap back into view. - 16ms delay matches 60fps. For smoother animation on high refresh displays, use
withFrameMillisfrom the animation framework. - MutableList vs State: The mutable list plus a tick counter is a deliberate choice; the tick state change triggers recomposition which re-reads the Canvas.
- Scale to hundreds of particles easily since Canvas draws are GPU-batched, unlike individual Composable nodes.
Minimum SDK: API 24+ with Compose BOM 2024.01+