Implicit and explicit animations

Flutter · Advanced · 16 min

Motion makes apps feel polished and helps users follow changes, and Flutter makes it cheap to add.

Implicit animations are the easy path: widgets such as AnimatedContainer, AnimatedOpacity, AnimatedAlign, AnimatedPadding, AnimatedSwitcher and AnimatedCrossFade animate automatically whenever their target properties change. Give them a duration and optional curve, then just change values with setState. TweenAnimationBuilder does the same for any value.

Explicit animations give full control — looping, reversing, staggering, driving by a gesture:

  • An `AnimationController` produces values from 0.0 to 1.0 over a duration, ticking once per frame. It needs a vsync: mix SingleTickerProviderStateMixin (or TickerProviderStateMixin for several controllers) into your State and pass this. Create it in initState, dispose it in dispose.
  • Control it with forward(), reverse(), repeat(), animateTo(), stop().
  • A `Tween` maps 0–1 to a range: Tween(begin: 0.0, end: 200.0).animate(CurvedAnimation(parent: controller, curve: Curves.easeOut)).
  • Rebuild with `AnimatedBuilder` (or transition widgets like FadeTransition, SlideTransition, ScaleTransition) so only the animated subtree rebuilds each frame.

Hero animates a shared element between routes: wrap both widgets in Hero(tag: sameTag, child: ...).

Prefer transitions that animate opacity and transforms; they're cheap to render. Respect MediaQuery.disableAnimationsOf(context) for users who reduce motion.

Example

import 'package:flutter/material.dart';
class Pulse extends StatefulWidget {
  const Pulse({super.key});
  @override
  State<Pulse> createState() => _PulseState();
}
class _PulseState extends State<Pulse> with SingleTickerProviderStateMixin {
  late final _controller = AnimationController(vsync: this, duration: const Duration(milliseconds: 800))
    ..repeat(reverse: true);
  late final _scale = Tween(begin: 0.8, end: 1.2)
      .animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut));
  bool _expanded = false;
  @override
  void dispose() { _controller.dispose(); super.dispose(); }
  @override
  Widget build(BuildContext context) => Column(children: [
        ScaleTransition(scale: _scale, child: const Icon(Icons.favorite, size: 48)),  // explicit
        GestureDetector(
          onTap: () => setState(() => _expanded = !_expanded),
          child: AnimatedContainer(                                                  // implicit
            duration: const Duration(milliseconds: 300), curve: Curves.easeOut,
            width: _expanded ? 200 : 100, height: 50, color: _expanded ? Colors.teal : Colors.grey),
        ),
      ]);
}

Remember

  • Implicit Animated* widgets animate when their properties change
  • AnimationController + Tween + a transition/AnimatedBuilder for full control
  • Controllers need a TickerProvider mixin and must be disposed

Watch out for

  • Calling setState in an animation listener to rebuild the whole screen every frame instead of using AnimatedBuilder or a transition.

Check yourself

  1. What does `SingleTickerProviderStateMixin` provide to an AnimationController?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.flutter.dev/ui/animations