Rainy
Continuous rain streaks falling at an angle, with depth based parallax and optional ground splash marks.
Rain looks complex on screen, but it reduces to a small idea: a stream of recyclable line segments, each with its own velocity. There is no fluid simulation, no particle physics, and no per frame allocation. The only state per drop is a position, a speed, and a length. Once a drop falls past the bottom, it teleports back to the top with a new horizontal offset, and the eye reads that recycled buffer as an endless downpour.
In this article, you'll explore how AnimationExample21 builds a continuous rain effect with withFrameNanos, how ANGLE_DEG becomes a velocity vector through cos and sin, how a fixed pool of Drop21 objects is recycled to fake infinite rain, and how drawLine plus an optional drawCircle paint the streaks and splashes on a Canvas.
How the example is structured
The composable is built around three pieces that work together inside a BoxWithConstraints.
- A list of
Drop21objects:DROP_COUNT = 100mutable records, each holdingx,y, a per dropspeed, and a per droplength. The speed range encodes depth: slower drops read as far away, faster drops read as foreground. - A
LaunchedEffectdriven bywithFrameNanos: a single coroutine advances every drop byvelocity * dteach frame, then recycles any drop that left the visible region. - A
Canvasthat draws each drop as a slanted line: with a head to tail gradient, an optionaldrawCirclesplash mark when the drop touches the bottom edge, and a vertical gradient background painted underneath.
That is the full architecture. No physics engine, no Animatable, no per drop coroutine. The whole effect is one frame loop and one Canvas pass over a fixed pool.
Modeling a drop
The data class is intentionally minimal. Position is mutable because it changes every frame. Speed and length are fixed at spawn time, which is what gives each drop a stable identity as it falls.
private data class Drop21(
var x: Float,
var y: Float,
val speed: Float,
val length: Float,
)
Notice the structure: speed doubles as a depth cue. The example seeds it from SPEED_MIN_DP_PER_SEC = 180f to SPEED_MAX_DP_PER_SEC = 360f, so two drops on the same row can move at very different rates. Faster drops sweep across the foreground, slower drops drift behind, and the brain reads the spread as parallax.
length works the same way. Each drop samples a length between STREAK_LENGTH_MIN_DP = 14f and STREAK_LENGTH_MAX_DP = 28f, and the longer streaks read as more motion blur. The drawing code later combines length with a head to tail alpha gradient, so longer drops also fade more gradually, reinforcing the depth illusion without any explicit alpha per drop.
The pool itself is built once inside remember, keyed on the inputs that affect spawn geometry:
val drops = remember(widthPx, heightPx, DROP_COUNT, /* ... */) {
val rng = Random(0xCAFEBABEL)
List(DROP_COUNT) {
Drop21(
x = spawnXMin + rng.nextFloat() * spawnXSpan,
y = rng.nextFloat() * heightPx,
speed = speedMinPx + rng.nextFloat() * (speedMaxPx - speedMinPx),
length = streakMinPx + rng.nextFloat() * (streakMaxPx - streakMinPx),
)
}
}
The fixed seed 0xCAFEBABEL makes the initial layout reproducible across recompositions, and the initial y is spread across the full height, so the rain does not start as a single horizontal band crashing down from the top.
Direction from ANGLE_DEG
The slant of the rain is not hardcoded. It comes from a single value, ANGLE_DEG, that the source converts into a unit vector once per recomposition.
val angleRad = (ANGLE_DEG * PI / 180.0).toFloat()
val dirX = cos(angleRad)
val dirY = sin(angleRad)
With ANGLE_DEG = 100f, the angle is just past 90 degrees, so sin(angleRad) is close to 1.0 (mostly downward) and cos(angleRad) is slightly negative (a small leftward push). That matches the comment in the source: 90 = straight down, 135 = strong left slant; 95 to 120 looks like wind blown rain.
Because dirX and dirY are reused as a unit vector, every drop's velocity is (dirX * speed, dirY * speed). The angle changes the slant of the streaks. The per drop speed changes how fast each one travels along that shared direction. Decoupling direction from magnitude is what lets a single ANGLE_DEG retune the entire scene without touching the drop pool.
There is one safety adjustment worth noting:
val safeDirY = if (dirY > 0.05f) dirY else 0.05f
val horizontalDrift = kotlin.math.abs(dirX) * heightPx / safeDirY
If dirY ever became zero or negative, the math that figures out how far off screen drops can spawn would blow up or go wrong. Clamping it to at least 0.05f keeps the spawn region finite even at extreme angles, which is the kind of small guardrail that prevents nonsense at edge cases.
Recycling drops at the bottom
The frame loop is the heart of the animation. It runs forever inside a LaunchedEffect, waits for each frame with withFrameNanos, computes the elapsed time dt in seconds, and advances every drop.
LaunchedEffect(/* keys */) {
var lastNanos = 0L
val rng = Random(0xBEEFCAFEL)
while (true) {
withFrameNanos { now ->
val dt = if (lastNanos == 0L) 0f
else ((now - lastNanos) / 1_000_000_000f).coerceAtMost(0.05f)
lastNanos = now
for (d in drops) {
d.x += dirX * d.speed * dt
d.y += dirY * d.speed * dt
if (d.y - d.length > heightPx ||
d.x + d.length < spawnXMin ||
d.x - d.length > spawnXMax) {
d.x = spawnXMin + rng.nextFloat() * spawnXSpan
d.y = -d.length - rng.nextFloat() * heightPx * 0.3f
}
}
tick++
}
}
}
A few details matter here. dt is clamped to 0.05f, so a paused tab or a long jank spike cannot teleport every drop halfway down the screen on the next frame. Movement is pure velocity * dt, which keeps the perceived speed independent of frame rate, so the same scene looks identical on a 60 Hz panel and a 120 Hz panel.
The recycle test checks all three exit edges: the bottom (d.y - d.length > heightPx) and both sides (d.x + d.length < spawnXMin, d.x - d.length > spawnXMax). The side checks matter because at a steep slant, drops can leave through the left or right before they ever reach the floor. When a drop is recycled, it is reseeded above the top with a small random vertical offset (-rng.nextFloat() * heightPx * 0.3f), which prevents the entire pool from re entering in a single visible row.
This is what gives the illusion of infinite rain with a finite buffer. One hundred drops are reused over and over, but the eye cannot match a recycled drop to its earlier life, so the stream looks continuous.
The tick++ at the end has one job: it forces the Canvas to invalidate. The drop list is mutable state outside of Compose, so the Canvas would not know to redraw without a snapshot backed read. Reading tick inside the draw block creates that read.
Drawing streaks and splashes
Each drop becomes a single drawLine from a tail point back behind the drop to the head at the drop's current position.
val headX = d.x
val headY = d.y
val tailX = headX - dirX * d.length
val tailY = headY - dirY * d.length
drawLine(
brush = Brush.linearGradient(
colors = listOf(RAIN_HEAD_COLOR.copy(alpha = RAIN_TAIL_ALPHA), RAIN_HEAD_COLOR),
start = Offset(tailX, tailY),
end = Offset(headX, headY),
),
start = Offset(tailX, tailY),
end = Offset(headX, headY),
strokeWidth = strokeWidthPx,
cap = StrokeCap.Round,
)
The tail is computed by walking d.length pixels backward along the same direction vector that drives motion, so the streak is always aligned with travel. The gradient runs from RAIN_TAIL_ALPHA = 0.0f at the tail to full RAIN_HEAD_COLOR = Color(0xFFB3E5FC) at the head, which produces a soft fade out trail without needing any per pixel work. StrokeCap.Round rounds the ends so the head reads as a small bright bead instead of a hard rectangle.
The splash is a guard plus a circle:
if (SPLASH_ENABLED && headY in (size.height - 2f)..(size.height + 2f)) {
drawCircle(
color = RAIN_HEAD_COLOR.copy(alpha = 0.7f),
radius = splashRadiusPx,
center = Offset(headX, size.height - 1f),
)
}
The window size.height - 2f to size.height + 2f is wide enough that even fast drops crossing several pixels per frame still register at the floor at least once. The splash is anchored to size.height - 1f so it always sits exactly on the bottom edge regardless of where the head landed inside that two pixel band.
Tweaking count, angle, speed, length, splash
Each constant has a direct visual effect. Knowing what they do lets you retune the scene without touching the loop.
DROP_COUNT: density of the rain. The source notes40 (drizzle) ↔ 500 (downpour). Cost scales linearly with count because every drop is onedrawLineper frame plus one position update.ANGLE_DEG: slant.90is straight down,100(the default) is light wind,135is a strong left slant. Anything below about91will hit thesafeDirYclamp and stop slanting further.SPEED_MIN_DP_PER_SECandSPEED_MAX_DP_PER_SEC: the parallax range. A wider gap between180fand360fmakes depth more obvious. Collapsing them to one value flattens the scene into a single sheet of rain.STREAK_LENGTH_MIN_DPandSTREAK_LENGTH_MAX_DP: motion blur. Longer streaks look faster even ifspeedis unchanged, because the eye reads streak length as exposure time.STROKE_WIDTH_DP: weight. The default1.1fis a light shower. The source range0.6to3.0covers everything from drizzle to heavy rain.SPLASH_ENABLED: ground splash marks. Turning it off saves onedrawCircleper landing drop and gives a cleaner, more abstract look.
Conclusion
In this article, you've explored how AnimationExample21 builds a continuous rain effect from a fixed pool of Drop21 objects, a single withFrameNanos loop, and one Canvas pass. Direction comes from cos(angleRad) and sin(angleRad), recycling at the bottom fakes infinite rain with a finite buffer, and the streak gradient plus optional splash circle handle the entire visual on top of a vertical gradient background.
Understanding these internals helps you reason about cost and behavior. Frame work is O(DROP_COUNT) for both the update and the draw, dt clamping protects against jank spikes, and depth comes for free from a per drop speed range rather than from any explicit z coordinate. The same pattern, a small mutable pool advanced by velocity * dt and recycled at the edges, generalizes to snow, sparks, dust, and starfields.
Whether you're adding atmosphere to a splash screen, building a mood layer behind a sign in form, or composing a more elaborate weather scene, this knowledge gives you a small kit of moving parts that you can retune entirely from a handful of constants. Pick the angle, pick the count, pick the speed range, and the rest of the system follows.
As always, happy coding!
— Jaewoong (skydoves)