Why Your App Needs More Than a CircularProgressIndicator
Standard material design components are great for speed, but they often make apps look identical. If you are building a fitness tracker, a coffee-ordering app, or a battery monitor, a simple spinning circle doesn't convey the right 'feeling.' A liquid wave animation adds a tactile, physical quality to your interface that delights users.
In Flutter, the most efficient way to achieve this is not through heavy GIFs or Lottie files, but by using the CustomPainter. This allows you to draw directly on the canvas and manipulate shapes in real-time using mathematical functions.
The Logic Behind the Wave
To create a wave, we use a sine wave formula: y = A * sin(kx + ωt). In simpler terms, we calculate the height of the water at every point across the screen based on the current time and the desired progress level. By updating a 'horizontal shift' value over time, the wave appears to move forward.
Step 1: Creating the WavePainter
First, we define a class that extends CustomPainter. This class will handle the actual drawing of the blue liquid and the white crest of the wave.
import 'dart:math' as math;
import 'package:flutter/material.dart';
class WavePainter extends CustomPainter {
final double progress;
final double animationValue;
WavePainter({required this.progress, required this.animationValue});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()..color = Colors.blueAccent.withOpacity(0.7);
final path = Path();
double waveHeight = 15.0; // The amplitude
double baseHeight = size.height * (1 - progress);
path.moveTo(0, size.height);
for (double i = 0; i <= size.width; i++) {
path.lineTo(
i,
baseHeight + math.sin((i / size.width * 2 * math.pi) + (animationValue * 2 * math.pi)) * waveHeight,
);
}
path.lineTo(size.width, size.height);
path.close();
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}
Step 2: Driving the Animation
To make the wave move, we need an AnimationController. This controller provides a linear value from 0.0 to 1.0, which we pass to our painter to shift the sine wave's phase.
class LiquidProgress extends StatefulWidget {
@override
_LiquidProgressState createState() => _LiquidProgressState();
}
class _LiquidProgressState extends State<LiquidProgress> with SingleTickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 2),
)..repeat();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return CustomPaint(
painter: WavePainter(
progress: 0.6, // 60% full
animationValue: _controller.value,
),
child: Container(height: 300, width: 300),
);
},
);
}
}
Optimizing for Performance
When using CustomPainter, performance is key. Because we are calling shouldRepaint: true, the canvas redraws 60 times per second. To keep this smooth, avoid heavy object allocation inside the paint method. For example, define your Paint objects outside the loop or reuse them. Also, wrap your CustomPaint in a RepaintBoundary if it is part of a complex widget tree to prevent unnecessary repaints of the entire screen.
By mastering the CustomPainter, you move from being a developer who just assembles widgets to one who can create entirely new visual experiences.

