Understand the internal mechanics of implicit animations and finely drive your own AnimationController.
Open this lesson in KodokonAnimatedContainer 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.
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,
),
),
),
);
}
}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.
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,
),
);
}
}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.
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,
);
},
);
}
}