flutter official logo

Crafting Liquid Wave Animations in Flutter

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.