Rainy

Continuous rain streaks falling at an angle, with depth based parallax and optional ground splash marks.

Compose APIs
withFrameNanosCanvasdrawLine
Try tweaking
DROP_COUNTANGLE_DEGSPEED_MIN/MAX_DP_PER_SECSTREAK_LENGTHSPLASH_ENABLED

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 Drop21 objects: DROP_COUNT = 100 mutable records, each holding x, y, a per drop speed, and a per drop length. The speed range encodes depth: slower drops read as far away, faster drops read as foreground.
  • A LaunchedEffect driven by withFrameNanos: a single coroutine advances every drop by velocity * dt each frame, then recycles any drop that left the visible region.
  • A Canvas that draws each drop as a slanted line: with a head to tail gradient, an optional drawCircle splash 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 notes 40 (drizzle) ↔ 500 (downpour). Cost scales linearly with count because every drop is one drawLine per frame plus one position update.
  • ANGLE_DEG: slant. 90 is straight down, 100 (the default) is light wind, 135 is a strong left slant. Anything below about 91 will hit the safeDirY clamp and stop slanting further.
  • SPEED_MIN_DP_PER_SEC and SPEED_MAX_DP_PER_SEC: the parallax range. A wider gap between 180f and 360f makes depth more obvious. Collapsing them to one value flattens the scene into a single sheet of rain.
  • STREAK_LENGTH_MIN_DP and STREAK_LENGTH_MAX_DP: motion blur. Longer streaks look faster even if speed is unchanged, because the eye reads streak length as exposure time.
  • STROKE_WIDTH_DP: weight. The default 1.1f is a light shower. The source range 0.6 to 3.0 covers everything from drizzle to heavy rain.
  • SPLASH_ENABLED: ground splash marks. Turning it off saves one drawCircle per 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)