1. Implicit Animations
An implicit animation widget animates automatically, on its own, whenever one of its properties changes between builds — no controller, no explicit start/stop, just a new target value.
class ExpandingCard extends StatefulWidget {
const ExpandingCard({super.key});
@override
State<ExpandingCard> createState() => _ExpandingCardState();
}
class _ExpandingCardState extends State<ExpandingCard> {
bool _expanded = false;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => setState(() => _expanded = !_expanded),
child: AnimatedContainer(
duration: const Duration(milliseconds: 250),
curve: Curves.easeInOut,
height: _expanded ? 200 : 80,
color: _expanded ? Colors.deepPurple : Colors.deepPurple.shade200,
),
);
}
}
The setState call just changes the target values (height,
color) — AnimatedContainer itself handles interpolating
smoothly between the old and new values over duration. No manual
tweening code required for this class of animation at all.
2. AnimatedOpacity & Friends
AnimatedOpacity(
opacity: _visible ? 1.0 : 0.0,
duration: const Duration(milliseconds: 300),
child: const Text('Now you see me'),
)
The same pattern repeats across Flutter's implicit animation family —
AnimatedAlign, AnimatedPadding, AnimatedDefaultTextStyle,
and several more — each animates exactly one kind of property change automatically,
which covers a large share of real UI polish with almost no code.
3. Explicit Animations with AnimationController
Once an animation needs to run on a loop, be paused and resumed manually, or
combine multiple properties on custom timing curves independently, implicit
animations run out of road — AnimationController gives full manual
control.
class PulsingIcon extends StatefulWidget {
const PulsingIcon({super.key});
@override
State<PulsingIcon> createState() => _PulsingIconState();
}
class _PulsingIconState extends State<PulsingIcon> with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<double> _scale;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 800),
)..repeat(reverse: true); // loop forward, then backward, indefinitely
_scale = Tween<double>(begin: 1.0, end: 1.3).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
);
}
@override
void dispose() {
_controller.dispose(); // controllers must be disposed
super.dispose();
}
@override
Widget build(BuildContext context) {
return ScaleTransition(scale: _scale, child: const Icon(Icons.favorite));
}
}
SingleTickerProviderStateMixin supplies the vsync that
ties the animation's frame updates to the screen's actual refresh cycle — this is
what keeps an off-screen animation from wasting cycles rendering frames nobody sees.
4. Hands-on Exercise
Add real motion to an earlier screen
Apply both implicit and explicit animations to polish a screen from an earlier week.
Requirements:
- An
AnimatedContaineror similar implicit animation on at least one state change in an earlier screen (e.g. a card expanding, a button changing color on selection). - An
AnimatedOpacityfade-in for content that loads asynchronously (pairs naturally with Week 9's loading states). - A custom looping animation built with
AnimationControllerandTween, used for something genuinely ongoing (a pulsing "live" indicator, a loading spinner variant) — not just a one-shot transition an implicit widget could have handled. - Confirm the
AnimationControlleris disposed correctly by navigating to and away from the screen repeatedly and checking for no memory or performance warnings. - A short comment explaining, for each animation you added, why it was implicit or explicit — tying the choice back to this week's criteria.
If an AnimationController-based animation appears to stutter or not run at all, confirm vsync: this is actually wired up via SingleTickerProviderStateMixin (or TickerProviderStateMixin for multiple controllers) on the State class — a missing or misconfigured vsync is the most common reason an explicit animation doesn't run smoothly.
5. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
Why doesn't AnimatedContainer need a Tween or a manual interpolation step the way an explicit animation does?
Why doesn't AnimatedContainer need a Tween or a manual interpolation step the way an explicit animation does?
Implicit animation widgets handle interpolation internally — they compare the previous build's property values to the new ones and automatically tween between them over the given duration. The developer only ever provides target values via setState; the widget itself is responsible for the actual animated transition.
Q2
What specific animation need pushes you from implicit animations to AnimationController?
What specific animation need pushes you from implicit animations to AnimationController?
Anything that isn't just a one-shot response to a property change — looping indefinitely, pausing and resuming on demand, or precisely coordinating multiple animated values on independent custom curves and timing — none of which an implicit widget, which only animates automatically between two states, is built to support.
Q3
What does vsync: this actually provide to an AnimationController, and why does it matter for performance?
What does vsync: this actually provide to an AnimationController, and why does it matter for performance?
It ties the animation's frame callbacks to the actual device screen refresh cycle via a Ticker, and — critically — the mixin providing it also suspends those ticks when the associated widget isn't visible on screen. Without a correctly configured vsync, an animation can keep computing and rendering frames even when nothing is actually displaying it, wasting CPU and battery.
Q4
Why must an AnimationController be disposed in the widget's dispose() method?
Why must an AnimationController be disposed in the widget's dispose() method?
It holds a real Ticker resource tied into the rendering pipeline and keeps firing frame callbacks on its own schedule — leaving it undisposed after its owning widget is gone means it continues consuming resources and attempting to drive an animation for a widget tree that no longer exists, a leak with no automatic cleanup unless dispose() is called explicitly.