flutter official logo

Building a Liquid Wave Progress Indicator with Flutter CustomPainter

Going Beyond Standard Widgets

Flutter's built-in CircularProgressIndicator is functional, but it doesn't always fit the aesthetic of a modern, highly polished mobile app. When you want to provide visual feedback that feels organic and fluid, you need to step away from high-level widgets and dive into the CustomPainter class. This tool gives you direct access to the canvas, allowing you to draw complex shapes and animations that are highly performant.

Understanding the Wave Physics

To create a liquid effect, we use a sine wave mathematical formula: y = A * sin(kx + wt). In the context of a Flutter canvas, this means calculating the vertical position of points across the width of the screen and connecting them to form a path. By linking the phase shift of the sine wave to an AnimationController, we create the illusion of flowing water.

Step 1: Creating the CustomPainter

The CustomPainter requires two main overrides: paint and shouldRepaint. In the paint method, we define the wave path. We start from the bottom-left corner, draw the curve across the top, and close the path at the bottom-right.

import 'dart:math' as math;
import 'package:flutter/material.dart';

class WavePainter extends CustomPainter {
  final double animationValue;
  final Color color;

  WavePainter({required this.animationValue, required this.color});

  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()..color = color;
    final path = Path();

    final waveHeight = size.height * 0.15;
    final waveLength = size.width;

    path.moveTo(0, size.height);
    for (double i = 0; i <= size.width; i++) {
      // Sine wave calculation
      double dx = i;
      double dy = size.height * 0.5 +
          math.sin((i / waveLength * 2 * math.pi) + (animationValue * 2 * math.pi)) *
              waveHeight;
      path.lineTo(dx, dy);
    }

    path.lineTo(size.width, size.height);
    path.close();
    canvas.drawPath(path, paint);
  }

  @override
  bool shouldRepaint(WavePainter oldDelegate) => 
      oldDelegate.animationValue != animationValue;
}

Step 2: Driving the Animation

To make the wave move, we need a StatefulWidget that manages an AnimationController. We pass the controller's value into our painter. Using an AnimatedBuilder ensures that only the painter is rebuilt during each frame, which is critical for maintaining 60 frames per second.

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(
            animationValue: _controller.value, 
            color: Colors.blue.withOpacity(0.5)
          ),
          child: Container(height: 200, width: double.infinity),
        );
      },
    );
  }
}

Performance Best Practices

When working with CustomPainter, performance is key. First, never instantiate your Paint object inside the for loop of your paint method; define it once at the start. Second, always implement the shouldRepaint logic correctly. By checking if the animationValue has actually changed, you prevent the engine from performing unnecessary calculations during static frames.

This approach allows you to layer multiple waves with different colors and speeds, creating a deep, parallax-style liquid effect that elevates your application's UI design.