Kodokon kodokon.com

Animations: implicit and explicit under the hood

Understand the internal mechanics of implicit animations and finely drive your own AnimationController.

10 min · 3 questions

Open this lesson in Kodokon

AnimatedContainer belongs to the family of implicit animations: you change a target value and the framework animates the transition. Under the hood, every ImplicitlyAnimatedWidget holds an AnimationController hidden in its state. When a new configuration arrives, didUpdateWidget walks the properties via forEachTween and re-targets each tween: the begin bound becomes the current interpolated value, the end bound the new target, then the controller restarts from zero. A valuable consequence: changing the target mid-animation causes no visual jump - the animation resumes exactly where it was.

DART
import 'package:flutter/material.dart';

class SelectableCard extends StatefulWidget {
  const SelectableCard({super.key});

  @override
  State<SelectableCard> createState() =>
      _SelectableCardState();
}

class _SelectableCardState extends State<SelectableCard> {
  bool _selected = false;

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () =>
          setState(() => _selected = !_selected),
      child: AnimatedContainer(
        duration: const Duration(milliseconds: 300),
        curve: Curves.easeOutCubic,
        width: _selected ? 220 : 160,
        height: 96,
        decoration: BoxDecoration(
          color: _selected
              ? Colors.indigo
              : Colors.indigo.shade200,
          borderRadius: BorderRadius.circular(
            _selected ? 28 : 12,
          ),
        ),
      ),
    );
  }
}
Size, color and radius animated from a single setState.

Implicits cover the properties the framework anticipates, and TweenAnimationBuilder generalizes the process to any interpolatable value. As soon as you need to drive the animation - pause, reverse, repeat, react to status - switch to the explicit AnimationController. The vsync parameter expects a TickerProvider: the mixin creates a Ticker, a callback invoked on every frame by the SchedulerBinding. A little-known subtlety: TickerMode mutes the tickers of a hidden subtree (for instance under a covered route). Time keeps flowing, but no callback runs anymore - zero CPU wasted on invisible animations.

DART
import 'package:flutter/material.dart';

class RevealPanel extends StatefulWidget {
  const RevealPanel({super.key, required this.child});

  final Widget child;

  @override
  State<RevealPanel> createState() => _RevealPanelState();
}

class _RevealPanelState extends State<RevealPanel>
    with SingleTickerProviderStateMixin {
  late final AnimationController _controller =
      AnimationController(
    vsync: this,
    duration: const Duration(milliseconds: 400),
    reverseDuration: const Duration(milliseconds: 200),
  );

  late final Animation<double> _opacity = _controller
      .drive(CurveTween(curve: Curves.easeOutCubic));

  void toggle() {
    switch (_controller.status) {
      case AnimationStatus.forward:
      case AnimationStatus.completed:
        _controller.reverse();
      case AnimationStatus.reverse:
      case AnimationStatus.dismissed:
        _controller.forward();
    }
  }

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

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: toggle,
      child: FadeTransition(
        opacity: _opacity,
        child: widget.child,
      ),
    );
  }
}
reverseDuration: the way in and the way out each get their own tempo.

A controller is just an Animation<double> running from 0 to 1: compose it instead of consuming it raw. controller.drive(CurveTween(...)) produces a derived animation with no extra state. To apply the value to the tree, prefer the Transition widgets (FadeTransition, RotationTransition): they listen to the animation directly at the render object level, without rebuilding a single widget per frame. When the transformation is arbitrary, AnimatedBuilder does the job - provided you pass the static subtree through child: it's built only once then re-injected into builder on every frame. That's the highest-payoff optimization for explicit animations.

DART
import 'dart:math' as math;

import 'package:flutter/material.dart';

class Orbit extends StatefulWidget {
  const Orbit({super.key});

  @override
  State<Orbit> createState() => _OrbitState();
}

class _OrbitState extends State<Orbit>
    with SingleTickerProviderStateMixin {
  late final AnimationController _controller =
      AnimationController(
    vsync: this,
    duration: const Duration(seconds: 4),
  )..repeat();

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

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _controller,
      child: const Icon(Icons.satellite_alt, size: 32),
      builder: (context, child) {
        final angle = _controller.value * 2 * math.pi;
        return Transform.translate(
          offset: Offset(
            math.cos(angle) * 80,
            math.sin(angle) * 80,
          ),
          child: child,
        );
      },
    );
  }
}
The icon passed as child is built only once.

Knowledge check

Make sure you remember the key points of this lesson.

  1. An AnimatedContainer receives a new target color while the previous animation is halfway through. What happens?
    • The tween is re-targeted: it restarts from the current value toward the new target, with no visual jump
    • The running animation finishes first, then the new one starts
    • The widget jumps immediately to the new color
  2. What exactly is the vsync parameter of an AnimationController for?
    • It provides a Ticker synced to frames, which TickerMode can mute when the subtree is hidden
    • It syncs the controller with the app's other animations
    • It limits the animation to 30 frames per second
    • It enables GPU hardware acceleration
  3. Why pass the static subtree via AnimatedBuilder's child parameter?
    • It's built once then re-injected into builder, instead of being rebuilt on every frame
    • It's mandatory: builder can't return new widgets
    • It automatically releases the controller at the end