Lesson 71 of 77 advanced

Animations Deep Dive: Implicit, Explicit, Hero & CustomPainter

From simple fades to complex physics-based motion — master every animation layer in Flutter

Open interactive version (quiz + challenge)

Real-world analogy

Flutter animations are like a film production. Implicit animations are point-and-shoot cameras — you say 'go from A to B' and the camera handles framing, timing, and transitions automatically. Explicit animations are a full film crew — you control the camera (AnimationController), the lens (Tween), and the motion style (Curve) individually. Hero animations are like a match cut in cinema — the same object seamlessly transitions between two scenes. CustomPainter is your VFX studio — you paint every pixel by hand on a Canvas.

What is it?

Flutter's animation system spans three tiers: implicit animations (automatic transitions between property values), explicit animations (controller-driven with full timing control), and custom painting (Canvas-level pixel drawing). Hero animations handle shared-element transitions between routes. Physics-based animations use real-world simulation for natural motion.

Real-world relevance

In a production app, you'd use implicit animations for theme changes and button states, explicit animations for onboarding carousels and loading indicators, Hero transitions for image galleries (thumbnail → fullscreen), staggered animations for list item entrances, and CustomPainter for custom charts or progress rings. The choice depends on complexity — always start with the simplest tier that achieves the desired effect.

Key points

Code example

// Complete animation examples — from implicit to physics-based

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

// ===== 1. IMPLICIT ANIMATIONS =====

class ImplicitDemo extends StatefulWidget {
  const ImplicitDemo({super.key});
  @override
  State<ImplicitDemo> createState() => _ImplicitDemoState();
}

class _ImplicitDemoState extends State<ImplicitDemo> {
  bool _expanded = false;

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () => setState(() => _expanded = !_expanded),
      child: AnimatedContainer(
        duration: const Duration(milliseconds: 400),
        curve: Curves.easeInOut,
        width: _expanded ? 300 : 100,
        height: _expanded ? 300 : 100,
        decoration: BoxDecoration(
          color: _expanded ? Colors.blue : Colors.red,
          borderRadius: BorderRadius.circular(_expanded ? 24 : 60),
        ),
        child: AnimatedSwitcher(
          duration: const Duration(milliseconds: 300),
          child: _expanded
              ? const Icon(Icons.close, key: ValueKey('close'))
              : const Icon(Icons.add, key: ValueKey('add')),
        ),
      ),
    );
  }
}

// ===== 2. EXPLICIT ANIMATION — Staggered =====

class StaggeredAnimation extends StatefulWidget {
  const StaggeredAnimation({super.key});
  @override
  State<StaggeredAnimation> createState() => _StaggeredAnimationState();
}

class _StaggeredAnimationState extends State<StaggeredAnimation>
    with SingleTickerProviderStateMixin {
  late final AnimationController _controller;
  late final Animation<double> _opacity;
  late final Animation<Offset> _slide;
  late final Animation<double> _scale;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: const Duration(milliseconds: 1500),
      vsync: this,
    );

    // Staggered intervals — overlapping for fluid motion
    _opacity = Tween<double>(begin: 0, end: 1).animate(
      CurvedAnimation(
        parent: _controller,
        curve: const Interval(0.0, 0.4, curve: Curves.easeOut),
      ),
    );

    _slide = Tween<Offset>(
      begin: const Offset(0, 0.3),
      end: Offset.zero,
    ).animate(
      CurvedAnimation(
        parent: _controller,
        curve: const Interval(0.2, 0.7, curve: Curves.easeOut),
      ),
    );

    _scale = Tween<double>(begin: 0.8, end: 1.0).animate(
      CurvedAnimation(
        parent: _controller,
        curve: const Interval(0.5, 1.0, curve: Curves.elasticOut),
      ),
    );

    _controller.forward();
  }

  @override
  void dispose() {
    _controller.dispose(); // CRITICAL — prevents memory leak
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _controller,
      // Static child — NOT rebuilt on every frame
      child: const Card(child: Padding(
        padding: EdgeInsets.all(24),
        child: Text('Staggered!', style: TextStyle(fontSize: 24)),
      )),
      builder: (context, child) {
        return Opacity(
          opacity: _opacity.value,
          child: SlideTransition(
            position: _slide,
            child: ScaleTransition(
              scale: _scale,
              child: child, // Reuses the static child
            ),
          ),
        );
      },
    );
  }
}

// ===== 3. HERO TRANSITION with custom shuttle =====

class HeroSourceScreen extends StatelessWidget {
  const HeroSourceScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () => Navigator.push(
        context,
        MaterialPageRoute(builder: (_) => const HeroDestScreen()),
      ),
      child: Hero(
        tag: 'profile-hero',
        flightShuttleBuilder: (_, animation, __, fromCtx, ___) {
          return AnimatedBuilder(
            animation: animation,
            builder: (context, child) => Material(
              elevation: animation.value * 12,
              borderRadius: BorderRadius.circular(
                60 - (animation.value * 48), // Circle → rounded rect
              ),
              clipBehavior: Clip.antiAlias,
              child: Image.network(
                'https://example.com/avatar.jpg',
                width: 60 + (animation.value * 240),
                height: 60 + (animation.value * 240),
                fit: BoxFit.cover,
              ),
            ),
          );
        },
        child: const CircleAvatar(radius: 30),
      ),
    );
  }
}

class HeroDestScreen extends StatelessWidget {
  const HeroDestScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Hero(
      tag: 'profile-hero',
      child: Image.network(
        'https://example.com/avatar.jpg',
        width: 300, height: 300, fit: BoxFit.cover,
      ),
    );
  }
}

// ===== 4. CUSTOM PAINTER — Animated Ring =====

class AnimatedRingPainter extends CustomPainter {
  final double progress; // 0.0 → 1.0
  final Color color;

  AnimatedRingPainter({required this.progress, required this.color});

  @override
  void paint(Canvas canvas, Size size) {
    final center = Offset(size.width / 2, size.height / 2);
    final radius = math.min(size.width, size.height) / 2 - 10;

    // Background ring
    final bgPaint = Paint()
      ..color = color.withOpacity(0.2)
      ..strokeWidth = 8
      ..style = PaintingStyle.stroke;
    canvas.drawCircle(center, radius, bgPaint);

    // Progress arc
    final fgPaint = Paint()
      ..color = color
      ..strokeWidth = 8
      ..style = PaintingStyle.stroke
      ..strokeCap = StrokeCap.round;

    canvas.drawArc(
      Rect.fromCircle(center: center, radius: radius),
      -math.pi / 2,            // Start from top
      2 * math.pi * progress,  // Sweep angle
      false,
      fgPaint,
    );

    // Center text
    final textPainter = TextPainter(
      text: TextSpan(
        text: '${(progress * 100).toInt()}%',
        style: TextStyle(color: color, fontSize: 24, fontWeight: FontWeight.bold),
      ),
      textDirection: TextDirection.ltr,
    )..layout();
    textPainter.paint(
      canvas,
      center - Offset(textPainter.width / 2, textPainter.height / 2),
    );
  }

  @override
  bool shouldRepaint(AnimatedRingPainter old) =>
      old.progress != progress || old.color != color;
}

// ===== 5. PHYSICS-BASED ANIMATION =====

class SpringDemo extends StatefulWidget {
  const SpringDemo({super.key});
  @override
  State<SpringDemo> createState() => _SpringDemoState();
}

class _SpringDemoState extends State<SpringDemo>
    with SingleTickerProviderStateMixin {
  late final AnimationController _controller;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(vsync: this);
    _runSpring();
  }

  void _runSpring() {
    const spring = SpringDescription(
      mass: 1.0,
      stiffness: 180.0,
      damping: 12.0, // Underdamped → bouncy
    );
    final simulation = SpringSimulation(spring, 0, 1, -2); // velocity = -2
    _controller.animateWith(simulation);
  }

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

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _controller,
      builder: (context, child) {
        return Transform.translate(
          offset: Offset(0, _controller.value * 200 - 100),
          child: child,
        );
      },
      child: GestureDetector(
        onTap: () {
          _controller.reset();
          _runSpring();
        },
        child: Container(
          width: 80, height: 80,
          decoration: const BoxDecoration(
            color: Colors.orange,
            shape: BoxShape.circle,
          ),
        ),
      ),
    );
  }
}

Line-by-line walkthrough

  1. 1. ImplicitDemo: AnimatedContainer animates width, height, color, and borderRadius on tap — zero controllers, just setState
  2. 2. AnimatedSwitcher cross-fades icons — ValueKey tells Flutter these are different widgets that need a transition
  3. 3. StaggeredAnimation: single controller drives three Intervals — opacity (0-40%), slide (20-70%), scale (50-100%) overlap for fluid motion
  4. 4. Each Interval maps a portion of the controller's 0.0→1.0 to its own 0.0→1.0 — outside the interval, the value is clamped
  5. 5. AnimatedBuilder child parameter: the Card is built ONCE and reused every frame — only opacity/slide/scale wrapper rebuilds
  6. 6. _controller.dispose() in dispose() — prevents the ticker from firing after the widget is removed, avoiding the 'setState on disposed' error
  7. 7. HeroSourceScreen: flightShuttleBuilder customizes the mid-flight widget — morphing from circle (radius 60) to rectangle during navigation
  8. 8. AnimatedRingPainter: Canvas.drawArc with sweep angle based on progress — shouldRepaint compares progress values to avoid unnecessary redraws
  9. 9. TextPainter renders the percentage text centered on the canvas — layout() must be called before paint()
  10. 10. SpringSimulation: mass, stiffness, and damping control the bounce behavior — underdamped (low damping) creates visible oscillation
  11. 11. _controller.animateWith(simulation): the controller follows the physics simulation instead of a linear duration — no fixed end time
  12. 12. GestureDetector on the spring ball: reset + rerun creates a retrigger effect — interactive physics-based animation

Spot the bug

class FadeInWidget extends StatefulWidget {
  const FadeInWidget({super.key});
  @override
  State<FadeInWidget> createState() => _FadeInWidgetState();
}

class _FadeInWidgetState extends State<FadeInWidget>
    with SingleTickerProviderStateMixin {
  late final AnimationController _controller;
  late final Animation<double> _opacity;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: const Duration(milliseconds: 800),
      vsync: this,
    );
    _opacity = Tween<double>(begin: 0, end: 1).animate(_controller);
    _controller.forward();
  }

  @override
  Widget build(BuildContext context) {
    return Opacity(
      opacity: _opacity.value,
      child: const Text('Hello, World!'),
    );
  }
}
Need a hint?
The widget appears immediately at full opacity without any fade animation. The build method reads the animation value but doesn't know when it changes.
Show answer
Bug: The build() method reads _opacity.value once but never rebuilds when the animation ticks. There's no AnimatedBuilder or listener to trigger rebuilds. Fix: wrap the Opacity widget in an AnimatedBuilder: `AnimatedBuilder(animation: _opacity, builder: (context, child) => Opacity(opacity: _opacity.value, child: child), child: const Text('Hello, World!'))`. Alternatively, use `_controller.addListener(() => setState(() {}))` in initState — but AnimatedBuilder is preferred as it's more efficient and self-documenting. Also: don't forget `_controller.dispose()` in dispose() — this example leaks the controller.

Explain like I'm 5

Imagine you're playing with a toy car. Implicit animations are like pushing the car and it rolls smoothly by itself to where you pointed. Explicit animations are like a remote control car — you control the speed, direction, and when it starts and stops. CustomPainter is like drawing the car yourself with crayons, frame by frame, to make it look exactly how you want. Hero animations are like when a character walks through a doorway in a cartoon — the same character appears on both sides seamlessly.

Fun fact

The Flutter team at Google measured that apps with well-crafted animations see 15-30% higher user engagement and retention compared to apps with abrupt transitions. Apple's Human Interface Guidelines and Google's Material Motion spec both codify this: motion isn't decoration, it's communication. A well-timed 300ms ease-out tells the user 'this action succeeded' more clearly than any text label.

Hands-on challenge

Build an animated profile card that demonstrates all three animation tiers: (1) Use AnimatedContainer for the card background color change on tap. (2) Use an explicit AnimationController with staggered Intervals to animate the avatar (scale in at 0.0-0.4), name text (fade + slide at 0.2-0.6), and bio text (fade + slide at 0.4-0.8). (3) Use CustomPainter to draw an animated skill-level ring around the avatar. (4) Add a Hero transition so tapping the avatar navigates to a detail screen with the avatar flying to fullscreen. (5) Make the card bounce into view using SpringSimulation on first load.

More resources

Open interactive version (quiz + challenge) ← Back to course: Flutter Interview Mastery