Animations add life to your Flutter apps, and using CustomPainter
allows you to design beautiful, layered visuals with control and efficiency.
In this tutorial, we will build a Rainy Street Scene animation in Flutter using CustomPainter
, featuring:
✅ A twilight background with mountain reflections
✅ Animated raindrops
✅ Glowing streetlights with pulsing glow
✅ Random lightning flashes
This is an excellent project to practice advanced Flutter UI animations for your portfolio or YouTube channel.
Complete Source Code
Copy, paste, and run this in your Flutter environment:
import 'dart:async';
import 'dart:math' as math;
import 'dart:math';
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
home: const RainyStreetScene(),
);
}
}
class RainyStreetScene extends StatefulWidget {
const RainyStreetScene({super.key});
@override
State<RainyStreetScene> createState() => _RainyStreetSceneState();
}
class _RainyStreetSceneState extends State<RainyStreetScene>
with TickerProviderStateMixin {
late AnimationController _controller;
late AnimationController _bulbGlowController;
late Timer _thunderTimer;
late Animation<double> _glowRadius;
bool _showLightning = false;
@override
void initState() {
super.initState();
_bulbGlowController = AnimationController(
vsync: this,
duration: Duration(seconds: 2),
)..repeat(reverse: true);
_glowRadius = Tween<double>(begin: 20, end: 40).animate(
CurvedAnimation(parent: _bulbGlowController, curve: Curves.easeInOut),
);
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 2),
)..repeat();
_startThunderTimer();
}
void _startThunderTimer() {
_thunderTimer = Timer.periodic(const Duration(seconds: 5), (_) {
setState(() => _showLightning = true);
Future.delayed(const Duration(milliseconds: 200), () {
setState(() => _showLightning = false);
});
});
}
@override
void dispose() {
_controller.dispose();
_bulbGlowController.dispose();
_thunderTimer.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final random = Random();
final double randomTop =
random.nextDouble() * 300; // Adjust 300 as per screen height
final double randomLeft = random.nextDouble() * 200; // Adjust 200 as per
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: [
CustomPaint(
size: Size.infinite,
painter: MountainReflectionPainter(),
),
AnimatedBuilder(
animation: Listenable.merge([_controller, _glowRadius]),
builder: (_, __) {
return CustomPaint(
painter: StreetScenePainter(
rainProgress: _controller.value,
showLightning: _showLightning,
glowRadius: _glowRadius.value,
),
child: Container(),
);
},
),
// Thunder lightning flash with bolt
if (_showLightning) ...[
Positioned(
top: randomTop,
left: randomLeft,
child: CustomPaint(
size: Size(60, 120),
painter: LightningPainter(),
),
),
],
],
),
);
}
}
class MountainReflectionPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final paint = Paint();
// Draw background gradient (sunset)
final rect = Rect.fromLTWH(0, 0, size.width, size.height);
final gradient = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
// Ground dark
Color(0xFF1E3C72), // Twilight blue
Color(0xFF2A5298), // Cool night blue
Color(0xFF000000),
],
);
paint.shader = gradient.createShader(rect);
canvas.drawRect(rect, paint);
// Mountain silhouette
final mountainPath =
Path()
..moveTo(0, size.height * 0.4)
..lineTo(size.width * 0.3, size.height * 0.25)
..lineTo(size.width * 0.5, size.height * 0.3)
..lineTo(size.width * 0.7, size.height * 0.2)
..lineTo(size.width, size.height * 0.35)
..lineTo(size.width, size.height * 0.5)
..lineTo(0, size.height * 0.5)
..close();
paint.color = Colors.black87;
canvas.drawPath(mountainPath, paint);
// Reflection (flip the mountain)
canvas.save();
canvas.translate(0, size.height); // move to bottom
canvas.scale(1, -1); // flip vertically
paint.color = Colors.black38; // faded reflection
canvas.drawPath(mountainPath.shift(Offset(0, size.height * 0.1)), paint);
canvas.restore();
// Add water effect (blue overlay)
final waterRect = Rect.fromLTWH(
0,
size.height * 0.5,
size.width,
size.height * 0.5,
);
paint.shader = null;
paint.color = Colors.blue.withOpacity(0.2);
canvas.drawRect(waterRect, paint);
}
@override
bool shouldRepaint(CustomPainter oldDelegate) => false;
}
class LightningPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final path = Path();
path.moveTo(size.width * 0.3, 0);
path.lineTo(size.width * 0.6, size.height * 0.3);
path.lineTo(size.width * 0.4, size.height * 0.3);
path.lineTo(size.width * 0.7, size.height * 0.6);
path.lineTo(size.width * 0.5, size.height * 0.6);
path.lineTo(size.width * 0.8, size.height);
// Draw glow by painting the same path multiple times with blur effect
for (int i = 6; i >= 1; i--) {
final glowPaint =
Paint()
..color = Colors.white.withOpacity(0.02 * i)
..strokeWidth = 4.0 * i
..style = PaintingStyle.stroke
..maskFilter = MaskFilter.blur(BlurStyle.normal, 4.0 * i);
canvas.drawPath(path, glowPaint);
}
// Draw main lightning stroke
final paint =
Paint()
..color = Colors.white
..strokeWidth = 4
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round;
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(CustomPainter oldDelegate) => false;
}
class StreetScenePainter extends CustomPainter {
final double rainProgress;
final bool showLightning;
final Random _rand = Random();
final double glowRadius;
StreetScenePainter({
required this.rainProgress,
required this.showLightning,
required this.glowRadius,
});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint();
// Background sky
paint.color = showLightning ? Colors.white54 : Colors.transparent;
canvas.drawRect(Rect.fromLTWH(0, 0, size.width, size.height), paint);
// Road
paint.color = Colors.black87;
canvas.drawRect(
Rect.fromLTWH(0, size.height, size.width, size.height * 0.25),
paint,
);
// Streetlights
for (int i = 0; i < 5; i++) {
final scale = 1.2 - i * 0.2;
final dx = size.width * 0.15 + i * size.width * 0.17;
_drawStreetLight(canvas, dx, size.height * 0.8, scale);
}
// Raindrops
for (int i = 0; i < 100; i++) {
final x = _rand.nextDouble() * size.width;
final y = (_rand.nextDouble() + rainProgress) % 1 * size.height;
paint.color = Colors.blueGrey.shade200.withOpacity(0.4);
paint.strokeWidth = 1;
canvas.drawLine(Offset(x, y), Offset(x, y + 10), paint);
}
}
void _drawStreetLight(Canvas canvas, double x, double y, double scale) {
final paint = Paint()..color = Colors.black;
final path = Path();
// Pole
final poleHeight = 250.0 * scale;
final poleWidth = 4.0 * scale;
canvas.drawRect(
Rect.fromLTWH(x, y - poleHeight, poleWidth, poleHeight),
paint,
);
// Arc arm
final arcPath = Path();
arcPath.moveTo(x + poleWidth / 2, y - poleHeight);
arcPath.relativeQuadraticBezierTo(0, -30 * scale, 20 * scale, -30 * scale);
arcPath.relativeLineTo(0, 20 * scale);
canvas.drawPath(arcPath, paint);
// Bulb box
final bulbX = x + poleWidth / 2 + 18 * scale;
final bulbY = y - poleHeight - 10 * scale;
paint.color = Colors.grey.shade800;
canvas.drawRect(Rect.fromLTWH(bulbX, bulbY, 15 * scale, 15 * scale), paint);
// Glow
final glowPaint =
Paint()
..shader = RadialGradient(
colors: [Colors.yellow.withOpacity(0.6), Colors.transparent],
).createShader(
Rect.fromCircle(
center: Offset(bulbX + 7.5 * scale, bulbY + 7.5 * scale),
radius: glowRadius * scale,
),
);
canvas.drawCircle(
Offset(bulbX + 7.5 * scale, bulbY + 7.5 * scale),
glowRadius * scale,
glowPaint,
);
// Reflection on road
final reflectionPaint =
Paint()
..shader = LinearGradient(
colors: [Colors.yellow.withOpacity(0.3), Colors.transparent],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
).createShader(
Rect.fromLTWH(bulbX + 5 * scale, y, 5 * scale, 50 * scale),
);
canvas.drawRect(
Rect.fromLTWH(bulbX + 5 * scale, y, 5 * scale, 50 * scale),
reflectionPaint,
);
}
@override
bool shouldRepaint(covariant StreetScenePainter oldDelegate) => true;
}
Explanation of Key Components
We will now break down each part for clear understanding:
1️⃣ Main Structure
void main() => runApp(const MyApp());
2️⃣ RainyStreetScene Widget
A StatefulWidget
that:
✅ Initializes animation controllers for rain and glow
✅ Starts a timer for periodic lightning flashes
✅ Uses a Stack
to layer:
- The mountain background
- The animated rain and streetlights
- Random lightning strikes
The AnimationController
runs continuously to animate raindrops, while another controller animates the pulsing glow of the streetlights.
A timer triggers the _showLightning
flag every 5 seconds, briefly displaying a lightning flash.
3️⃣ MountainReflectionPainter
Uses CustomPainter
to:
✅ Draw a gradient twilight sky using LinearGradient
.
✅ Create black mountain silhouettes.
✅ Flip and fade the mountains below to simulate water reflection.
✅ Add a subtle blue overlay to mimic water.
This painter lays the static background of your scene.
4️⃣ StreetScenePainter
This painter adds:
✅ Glowing streetlights using radial gradients for glow.
✅ Streetlight reflections on the wet road using a linear gradient.
✅ Continuous raindrops drawn with canvas.drawLine
at random positions, animated with the rain progress.
✅ A lightning flash overlay (a translucent white overlay) when lightning occurs.
It uses:
AnimationController
for smooth rain animation.- The
glowRadius
animation for the pulsing glow effect on streetlights.
5️⃣ LightningPainter
- Creates a zigzag lightning bolt using
Path
: - Uses layered blurred strokes for glow.
- Uses a white stroke for the main bolt, making it pop during flashes.
6️⃣ Animation Details
- Rain Animation: Uses a 2-second repeating
AnimationController
to move raindrops from top to bottom, creating a seamless rain effect. - Glow Animation: Pulses the glow of streetlights between 20 and 40 radius smoothly using
CurvedAnimation
. - Lightning Timer: Every 5 seconds,
_showLightning
becomestrue
for 200ms, displaying a lightning flash and bolt at a random position.
Final Output
The animation will showcase:
- A night sky with mountains reflected in water.
- Continuous rain falling smoothly.
- Streetlights glowing gently.
- Lightning flashes randomly, illuminating the scene.
Why Use CustomPainter?
✅ Allows low-level drawing control for custom, layered visuals.
✅ Offers better performance for animations compared to stacking many widgets.
✅ Enables creative, unique UIs for your Flutter apps.
Customization Ideas
✅ Change the background colors for different moods (sunset, dawn, midnight rain).
✅ Add thunder and rain sound for realism.
✅ Adjust lightning frequency for heavy storms.
✅ Replace streetlights with lanterns or traffic lights for city scenes.
Final Thoughts
This project will help you learn:
✅ Layering animations and custom painters
✅ Using AnimationController
with CustomPainter
✅ Adding glow and reflection for realism
✅ Building complex animations efficiently
Once you understand this structure, you can create advanced, aesthetic Flutter animations that make your apps stand out.