DEV Community

Cover image for Flutter Breathing Pacer Animation Guide
Muhammad Uzair
Muhammad Uzair

Posted on Originally published at uzairxdev.online

Flutter Breathing Pacer Animation Guide

Breathing is the one animation where the user trusts the motion with their body. In my guided breathing and meditation app, Inhalo, the smooth animations and breathing pacers that guide users visually are the core of the product — backed by audio playback for guided meditation tracks and ambient sounds. When someone follows an inhale, the expanding circle on screen becomes their lungs. If it stutters, lags, or drifts off the real four-second count, they do not think "there is a bug" — they feel the exercise is wrong.

This guide walks through how to build a production-grade breathing pacer in Flutter: a phase-driven animation model, an expanding circle drawn with CustomPainter, haptic and audio cues at phase changes, and the performance habits that keep it locked at 60fps on low-end Android phones. Every snippet targets the current stable Flutter and Dart.

Model the breath cycle as phases

A breathing exercise is a small state machine. Box breathing is the simplest: inhale, hold, exhale, hold — four seconds each. Modeling each phase as data keeps the animation logic, the label, and the cues in one place:

enum BreathPhase { inhale, holdIn, exhale, holdOut }

class BreathCycle {
  const BreathCycle({
    required this.phases,
    this.label = 'Box breathing',
  });

  final String label;

  /// Each phase paired with its duration.
  final List<(BreathPhase phase, Duration duration)> phases;

  Duration get total =>
      phases.fold(Duration.zero, (sum, p) => sum + p.$2);

  static const box = BreathCycle(
    phases: [
      (BreathPhase.inhale, Duration(seconds: 4)),
      (BreathPhase.holdIn, Duration(seconds: 4)),
      (BreathPhase.exhale, Duration(seconds: 4)),
      (BreathPhase.holdOut, Duration(seconds: 4)),
    ],
  );
}
Enter fullscreen mode Exit fullscreen mode

Dart records keep this compact and readable. If you later want 4-7-8 breathing (inhale 4, hold 7, exhale 8), you define a new constant — no animation code changes.

Drive the phases with one AnimationController

Use a single AnimationController whose duration equals the whole cycle, then derive the current phase from the controller's value. This guarantees the visuals can never drift out of sync with the timing, because there is only one source of truth:

class BreathingPacer extends StatefulWidget {
  const BreathingPacer({super.key, this.cycle = BreathCycle.box});

  final BreathCycle cycle;

  @override
  State<BreathingPacer> createState() => _BreathingPacerState();
}

class _BreathingPacerState extends State<BreathingPacer>
    with SingleTickerProviderStateMixin {
  late final AnimationController _controller;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      vsync: this,
      duration: widget.cycle.total,
    )..repeat();
  }

  /// Which phase the animation is currently in, derived from progress.
  (BreathPhase, double) phaseAt(double progress) {
    final totalMs = widget.cycle.total.inMilliseconds;
    double elapsed = progress * totalMs;
    for (final (phase, duration) in widget.cycle.phases) {
      if (elapsed < duration.inMilliseconds) {
        return (phase, elapsed / duration.inMilliseconds);
      }
      elapsed -= duration.inMilliseconds;
    }
    return (widget.cycle.phases.first.$1, 0.0);
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _controller,
      builder: (context, child) {
        final (phase, phaseProgress) = phaseAt(_controller.value);
        return _PacerVisual(
          phase: phase,
          phaseProgress: phaseProgress,
          cycle: widget.cycle,
        );
      },
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Two things matter here. First, AnimatedBuilder rebuilds only the subtree below it — the rest of your screen never rebuilds on every frame. Second, the phase is pure math over controller.value, so pausing, seeking, or slowing the controller (for example with a user-controlled speed setting) moves everything in lockstep.

Draw the expanding circle with CustomPainter

The signature visual is a circle that grows on inhale, holds, and shrinks on exhale. CustomPainter gives you full control with almost no overhead:

class _PacerVisual extends StatelessWidget {
  const _PacerVisual({
    required this.phase,
    required this.phaseProgress,
    required this.cycle,
  });

  final BreathPhase phase;
  final double phaseProgress;
  final BreathCycle cycle;

  @override
  Widget build(BuildContext context) {
    return RepaintBoundary(
      child: CustomPaint(
        painter: _BreathPainter(
          phase: phase,
          progress: phaseProgress.clamp(0.0, 1.0),
        ),
        size: const Size.square(280),
      ),
    );
  }
}

class _BreathPainter extends CustomPainter {
  _BreathPainter({required this.phase, required this.progress});

  final BreathPhase phase;
  final double progress;

  @override
  void paint(Canvas canvas, Size size) {
    final center = size.center(Offset.zero);

    // Expansion curve: grow on inhale, hold, shrink on exhale, rest.
    final double expansion = switch (phase) {
      BreathPhase.inhale => Curves.easeInOut.transform(progress),
      BreathPhase.holdIn => 1.0,
      BreathPhase.exhale => 1.0 - Curves.easeInOut.transform(progress),
      BreathPhase.holdOut => 0.0,
    };

    final maxRadius = size.shortestSide / 2;
    final radius = maxRadius * (0.35 + 0.65 * expansion);

    // Soft glow ring behind the main circle.
    final glow = Paint()
      ..color = const Color(0xFF7FB069).withValues(alpha: 0.15)
      ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 24);
    canvas.drawCircle(center, radius * 1.12, glow);

    final fill = Paint()..color = const Color(0xFF4E6E5D);
    canvas.drawCircle(center, radius, fill);

    // A thin inner ring that pulses slightly ahead of the fill.
    final ring = Paint()
      ..color = const Color(0xFFF5F1E6).withValues(alpha: 0.6)
      ..style = PaintingStyle.stroke
      ..strokeWidth = 2;
    canvas.drawCircle(center, radius * (0.82 + 0.06 * expansion), ring);
  }

  @override
  bool shouldRepaint(_BreathPainter old) =>
      old.phase != phase || old.progress != progress;
}
Enter fullscreen mode Exit fullscreen mode

Wrapping the CustomPaint in a RepaintBoundary is the single biggest performance win: the circle repaints in its own layer instead of forcing the parent and siblings to repaint every frame. On older Android devices this is the difference between a smooth 60fps and visible judder.

Show the phase label without rebuilding the world

The label ("Breathe in", "Hold", "Breathe out") changes only four times per cycle, so it should not ride along with the per-frame rebuild. Derive it in the same AnimatedBuilder but push it into a ValueNotifier that only notifies on change:

class _PacerScreen extends StatefulWidget {
  const _PacerScreen();

  @override
  State<_PacerScreen> createState() => _PacerScreenState();
}

class _PacerScreenState extends State<_PacerScreen> {
  final _label = ValueNotifier<String>('Breathe in');

  static const _labels = {
    BreathPhase.inhale: 'Breathe in',
    BreathPhase.holdIn: 'Hold',
    BreathPhase.exhale: 'Breathe out',
    BreathPhase.holdOut: 'Rest',
  };

  void _onPhase(BreathPhase phase) {
    final next = _labels[phase]!;
    if (_label.value != next) {
      _label.value = next;
      HapticFeedback.lightImpact(); // Phase cue, no visual coupling.
    }
  }

  @override
  void dispose() {
    _label.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      children: [
        const BreathingPacer(),
        const SizedBox(height: 24),
        ValueListenableBuilder(
          valueListenable: _label,
          builder: (context, value, child) => AnimatedSwitcher(
            duration: const Duration(milliseconds: 300),
            child: Text(
              value,
              key: ValueKey(value),
              style: Theme.of(context).textTheme.headlineSmall,
            ),
          ),
        ),
      ],
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Wire _onPhase by adding a controller listener inside BreathingPacer (expose the current phase through a callback) — the pattern above keeps the 60fps painting path completely separate from the text updates. This separation also makes it easy to respect accessibility: check MediaQuery.disableAnimations and, when it is true, freeze the pacer and present a simple countdown instead of motion. (For more on building inclusive Flutter experiences, see my accessibility guide.)

Add haptic and audio cues

Haptics make phase changes unmistakable even when the user closes their eyes, which is exactly how breathing exercises are used. HapticFeedback.lightImpact() at each transition is enough — heavy vibration feels alarming, not calming. A quick guard keeps it from firing when the app is backgrounded:

import 'package:flutter/services.dart';

void cuePhaseChange() {
  // Safe to call from a controller listener; no-ops on unsupported devices.
  HapticFeedback.lightImpact();
}
Enter fullscreen mode Exit fullscreen mode

For audio cues, Inhalo plays guided meditation tracks and ambient sounds alongside the animation. The cleanest approach is to pre-download any audio so it plays offline — I cover that pattern in detail in Flutter Offline Audio: Cache and Play MP3s. A short chime at each phase change, started a few hundred milliseconds early, lands exactly when the visual transitions.

Keep it at 60fps

A breathing pacer runs for minutes at a time, so small inefficiencies compound into heat, battery drain, and dropped frames. The rules I follow in production:

  • RepaintBoundary around the CustomPaint. Mentioned twice because it is that important — it isolates per-frame repaints from the rest of the widget tree.
  • No allocations in paint(). Precompute your Paint objects as fields on the painter, not inside paint. The sample above creates them per frame for readability; in production, hoist them.
  • Const constructors everywhere else. Anything that does not depend on the animation value should be const so Flutter skips rebuilding it.
  • Prefer Curves transforms over manual math. Curves.easeInOut.transform(progress) is well-tested and cheap; hand-rolled easing usually introduces frame-to-frame discontinuities.
  • Test on a real low-end device. DevTools' performance overlay shows raster-thread time, which is where CustomPainter work lands. If raster time creeps above ~8ms per frame at 60fps, simplify the blur or the number of drawn layers.
  • Handle backgrounding. Listen to AppLifecycleState and pause the controller when the app is inactive; resume on return. An animation silently running in the background drains battery for nothing.

Verify the rhythm

A pacer that looks right but times wrong is worse than no pacer. A simple widget test pins the phase math so future refactors cannot break the timing:

import 'package:flutter_test/flutter_test.dart';

void main() {
  test('box breathing phases split the cycle evenly', () {
    const cycle = BreathCycle.box;
    expect(cycle.total, const Duration(seconds: 16));
    for (final (_, duration) in cycle.phases) {
      expect(duration, const Duration(seconds: 4));
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

For the full experience, I also validate with a stopwatch test on the controller: advance the test clock and assert that the phase derived at 25%, 50%, and 75% of the cycle matches inhale, hold, and exhale boundaries. Timing is the feature here — test it like one.

Takeaways

A great breathing pacer is not a fancy animation — it is a precise timer wearing a calm visual. Model the cycle as phase data, drive everything from one controller, paint the circle in an isolated layer, and keep text, haptics, and audio on their own update paths. Get those separations right and the pacer feels effortless: the user breathes with the circle and never thinks about the code at all.

Get in touch and let's talk about your project.


Originally published at uzairxdev.online

Top comments (0)