DEV Community

Cover image for I Made It Rain on Your Flutter Screen (Literally)
Azeem Hassan
Azeem Hassan

Posted on

I Made It Rain on Your Flutter Screen (Literally)

It was raining. I had nothing specific to work on. I had this idea for a rain-on-glass effect — drops with reflections, physics, wind — and thought: this doesn't exist for Flutter.

Six nights later, it does.

rainy_day is a Flutter package that renders a hyper-realistic rain-on-glass effect over any background image you give it. Falling drops. Reflections inside every drop. Collisions. Wind gusts. Accelerometer-driven parallax. One widget, one line.

This is the story of how I built it and what actually made it hard.


What it looks like

Drop this into your widget tree:

RainWidget(
  backgroundAsset: 'assets/images/background.jpg',
  blur: 10,
  fps: 60,
  enableCollisions: true,
  gravityThreshold: 3,
  windIntensity: 1.5,
  rainPresets: [
    RainPreset(3, 3, 0.88),
    RainPreset(5, 5, 0.90),
    RainPreset(6, 2, 1.00),
  ],
)
Enter fullscreen mode Exit fullscreen mode

And your screen becomes this:

demo gif

That's the whole API surface. Everything has sensible defaults. You don't need to manage a ticker, handle images, or touch a single canvas method.

But building that API took several nights of genuinely frustrating debugging. Here's what actually tripped me up.


Challenge 1: Reflections

This was the thing I underestimated the most.

Real drops on glass act like convex lenses — they refract what's behind them. So each drop in the scene shows a tiny, slightly distorted version of the background. Not the blurred version. The sharp version, at scale.

Flutter's Canvas doesn't have a CSS-style background-clip shortcut. You can't just say "fill this path with a section of this image." You have to do it manually.

What I ended up doing:

  1. At startup, scale down a copy of the original background image by a factor (default 5×). This is the "reflection image."
  2. At paint time, for every single drop, calculate where in the reflection image this drop's position maps to.
  3. Clip the canvas to the drop's Bézier path.
  4. Draw the mapped portion of the reflection image inside that clip region.
  5. Unclip, move to the next drop.

The math to get the reflection coordinate mapping right was where I spent most of my time. Parallax offset bleeds into it. Image scaling bleeds into it. Get either one slightly wrong and every drop reflection looks like it's sampling from the wrong part of the scene.


Challenge 2: Drop Shapes

I assumed drops were circles. They're not.

A bead sitting still is roughly circular. But a drop that's moving fast is a teardrop — elongated in the direction of travel, compressed at the sides. And a drop mid-collision briefly shifts sideways and deforms before stabilizing.

Three different Bézier path shapes depending on state:

if (r < 3) {
  // tiny static bead — plain oval
  path.addOval(Rect.fromCircle(center: Offset(x, y), radius: r));

} else if (colliding != null || (ySpeed ?? 0) > 2) {
  // fast or colliding — stretched teardrop
  final c = 1 + 0.1 * (ySpeed ?? 0);
  path.moveTo(x - r / c, y);
  path.cubicTo(x - r, y - 2 * r, x + r, y - 2 * r, x + r / c, y);
  path.cubicTo(x + r, y + c * r, x - r, y + c * r, x - r / c, y);
  path.close();

} else {
  // slow-moving — softer teardrop with asymmetric arcs
  final rr = 0.9 * r;
  path.moveTo(x - rr * 0.85, y);
  path.cubicTo(x - rr * 0.5, y - rr * 1.6, x + rr * 0.5, y - rr * 1.6, x + rr * 0.85, y);
  path.cubicTo(x + rr, y + rr * 1.1, x - rr, y + rr * 1.1, x - rr * 0.85, y);
  path.close();
}
Enter fullscreen mode Exit fullscreen mode

These paths are recomputed every frame for every visible drop. When you're running 600+ drops at 60fps, that adds up.


Challenge 3: Performance

My first version just called setState on a timer. Rebuilds the entire widget tree 60 times per second. With hundreds of drops and canvas operations per frame.

It was, generously: not great.

The fix was a CustomPainter that listens to a ValueNotifier directly:

class GlassPainter extends CustomPainter {
  GlassPainter({required Listenable repaint}) : super(repaint: repaint);

  @override
  bool shouldRepaint(GlassPainter _) => false; // never rebuild the painter
}
Enter fullscreen mode Exit fullscreen mode

shouldRepaint returns false — always. The painter structure doesn't change; only the data does. Flutter skips the full widget rebuild and just calls paint() directly on the canvas each tick.

Beyond that:

  • Spatial collision grid: drops only check cells adjacent to them, not every other drop (O(n) instead of O(n²))
  • Background blur via ImageFilter: applied at draw time on the canvas, not pre-blurred into a new image in memory
  • Reflection image pre-rendered once: not recomputed per frame

After all of this: smooth 60fps on a mid-range Android device.


Bonus: Wind That Feels Real

Once physics was solid, I wanted wind. Not a constant sideways push — actual gusts.

The implementation sums three sine waves at different frequencies and phases:

double _windForce(double t) {
  return options.windIntensity *
      (0.6 * math.sin(t * 0.0008) +
       0.3 * math.sin(t * 0.0021 + 1.2) +
       0.1 * math.sin(t * 0.0053 + 2.7));
}
Enter fullscreen mode Exit fullscreen mode

The result isn't random and isn't mechanical — it builds, peaks, recedes, and comes back differently each time. Exactly like actual gusts. Watching drops slowly drift sideways and then fall back down is probably my favorite thing in the whole package.


Getting Started

# pubspec.yaml
dependencies:
  rainy_day: ^1.0.1
Enter fullscreen mode Exit fullscreen mode

Don't forget to declare your image asset:

flutter:
  assets:
    - assets/images/background.jpg
Enter fullscreen mode Exit fullscreen mode

Then:

import 'package:rainy_day/rainy_day.dart';

// Minimal usage
RainWidget(backgroundAsset: 'assets/images/background.jpg')

// Stormy
RainWidget(
  backgroundAsset: 'assets/images/background.jpg',
  windIntensity: 3.0,
  gravityAngleVariance: 0.04,
  onControllerReady: (ctrl) {
    ctrl.speedMultiplier = 2.0;
    ctrl.spawnMultiplier = 4;
  },
)
Enter fullscreen mode Exit fullscreen mode

Works on Android and iOS. MIT licensed.


Links

If you find it useful, a ⭐ on GitHub or a 👍 on pub.dev goes a long way for discoverability. And if you build something with it — please share it, I genuinely want to see what you make.

It started as a rainy-night rabbit hole. Ended up being one of the most satisfying things I've shipped this year.

Top comments (0)