Animation #
Animation isn’t just decoration — it’s a communication tool that guides user attention, provides feedback, and makes transitions feel natural. Flutter provides a very complete animation system, from implicit animations needing one line of code, to explicit animations giving full control over every frame. Understanding both lets you choose the right tool for each situation.
Flutter’s Two Animation Categories #
To determine the right animation type, you need to understand the difference between implicit animation and explicit animation. The following flow diagram can help you choose the animation type that fits your needs:
flowchart TD
Start["Start: Need Animation?"] --> IsBuiltIn{"Is there a Built-in Implicit Widget?\n(AnimatedContainer, etc.)"}
IsBuiltIn -->|Yes| UseImplicit["Use built-in Implicit Widget"]
IsBuiltIn -->|No| IsCustomTween{"Is it a custom property animation\nbut without play/pause control?"}
IsCustomTween -->|Yes| UseTweenBuilder["Use TweenAnimationBuilder"]
IsCustomTween -->|No| NeedControl{"Do you need playback control?\n(Repeat, reverse, custom time range)"}
NeedControl -->|Yes| UseExplicit["Use Explicit Animation\n(AnimationController + AnimatedBuilder)"]
NeedControl -->|No| UseImplicitKustom["Use Custom Implicit Animation"]IMPLICIT ANIMATION (simple):
✓ Animates a single property (size, color, opacity)
✓ Set the target value, Flutter handles the transition
✓ No AnimationController needed
✓ Examples: AnimatedContainer, AnimatedOpacity, TweenAnimationBuilder
EXPLICIT ANIMATION (flexible):
✓ Full control over timing, playback, and lifecycle
✓ Repeating, reversing, or event-triggered animations
✓ Requires AnimationController
✓ Examples: AnimatedBuilder, AnimatedWidget, custom transitions
Choosing guide: use implicit animation if a built-in widget already meets the need. Use TweenAnimationBuilder for custom animations without a controller. Use explicit animation if you need full lifecycle control.
Implicit Animation — Automatic Animation #
AnimatedContainer #
AnimatedContainer is the animated version of Container — it automatically interpolates between property values when they change:
class _BoxState extends State<AnimatedBox> {
bool _expanded = false;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => setState(() => _expanded = !_expanded),
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
// All these properties are animated when they change
width: _expanded ? 200 : 100,
height: _expanded ? 200 : 100,
decoration: BoxDecoration(
color: _expanded ? Colors.blue : Colors.red,
borderRadius: BorderRadius.circular(_expanded ? 32 : 8),
boxShadow: [
BoxShadow(
blurRadius: _expanded ? 16 : 4,
color: Colors.black26,
),
],
),
child: const Icon(Icons.star, color: Colors.white),
),
);
}
}
Other Implicit Animation Widgets #
// AnimatedOpacity -- fade in/out
AnimatedOpacity(
opacity: _isVisible ? 1.0 : 0.0,
duration: const Duration(milliseconds: 200),
child: const Text('Text that can disappear'),
)
// AnimatedSize -- size changes smoothly
AnimatedSize(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
child: _isExpanded
? const LargeContent()
: const SmallContent(),
)
// AnimatedSwitcher -- fades between two different widgets
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
transitionBuilder: (child, animation) =>
FadeTransition(opacity: animation, child: child),
child: _isLoading
? const CircularProgressIndicator(key: ValueKey('loading'))
: Text('${_count}', key: ValueKey(_count)),
)
// AnimatedCrossFade -- cross-fades two widgets
AnimatedCrossFade(
duration: const Duration(milliseconds: 300),
crossFadeState: _showFirst
? CrossFadeState.showFirst
: CrossFadeState.showSecond,
firstChild: const LoginForm(),
secondChild: const RegisterForm(),
)
// AnimatedPositioned -- position inside a Stack
Stack(
children: [
AnimatedPositioned(
duration: const Duration(milliseconds: 400),
curve: Curves.bounceOut,
left: _atLeft ? 0 : 200,
top: _atTop ? 0 : 300,
child: const FloatingButton(),
),
],
)
TweenAnimationBuilder — Custom Implicit #
TweenAnimationBuilder reduces complexity, making property animations easy without needing an AnimationController. This is useful for simple animations requiring minimal setup.
// Custom numeric value animation
TweenAnimationBuilder<double>(
tween: Tween<double>(begin: 0, end: _targetValue),
duration: const Duration(milliseconds: 600),
curve: Curves.easeOut,
builder: (context, value, child) {
return Column(
children: [
// Use value to render
LinearProgressIndicator(value: value / 100),
Text('${value.toInt()}%'),
child!, // child is not rebuilt every frame
],
);
},
child: const Text('Progress'), // static -- not part of the animation
)
// Color animation
TweenAnimationBuilder<Color?>(
tween: ColorTween(begin: Colors.grey, end: _isActive ? Colors.green : Colors.red),
duration: const Duration(milliseconds: 300),
builder: (context, color, _) {
return Container(
color: color,
child: const Icon(Icons.circle, color: Colors.white),
);
},
)
Explicit Animation Foundations #
AnimationController — The Animation Engine #
AnimationController can be thought of as an engine that produces sequential values for animation whenever the device is ready. The controller must know the value range and duration in advance. Because it’s an engine, you can also make it stop, reverse, repeat, and reset.
class _AnimatedScreenState extends State<AnimatedScreen>
with SingleTickerProviderStateMixin { // required mixin for one controller
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 500),
vsync: this, // this = TickerProvider from the mixin
);
}
// Playback controls
void _play() => _controller.forward();
void _reverse() => _controller.reverse();
void _repeat() => _controller.repeat(reverse: true);
void _stop() => _controller.stop();
void _reset() => _controller.reset();
// Listen to animation status
void _listenStatus() {
_controller.addStatusListener((status) {
if (status == AnimationStatus.completed) {
print('Animation finished going forward');
} else if (status == AnimationStatus.dismissed) {
print('Animation finished going backward');
}
});
}
@override
void dispose() {
_controller.dispose(); // MANDATORY!
super.dispose();
}
}
// For multiple controllers at once:
class _MultiAnimState extends State<MultiAnim>
with TickerProviderStateMixin { // not Single-
late final AnimationController _controller1;
late final AnimationController _controller2;
}
Tween — Value Interpolation #
Tween is a stateless object that only accepts a start and end value. Tween’s only job is to define the mapping from the input range to the output range. The input range is generally 0.0 to 1.0, but it’s not required.
// Basic Tweens
final sizeTween = Tween<double>(begin: 0, end: 300);
final colorTween = ColorTween(begin: Colors.blue, end: Colors.red);
final offsetTween = Tween<Offset>(begin: Offset.zero, end: const Offset(1, 0));
// Attach the tween to the controller
final sizeAnimation = sizeTween.animate(_controller);
// Or directly with CurvedAnimation for curves:
final curvedAnimation = CurvedAnimation(
parent: _controller,
curve: Curves.easeInOut,
);
final sizeAnimation = sizeTween.animate(curvedAnimation);
// Use .value inside the builder
AnimatedBuilder(
animation: sizeAnimation,
builder: (context, _) {
return SizedBox(
width: sizeAnimation.value,
height: sizeAnimation.value,
);
},
)
Tween Types #
// Numeric
Tween<double>(begin: 0.0, end: 1.0)
Tween<int>(begin: 0, end: 100)
// Visual
ColorTween(begin: Colors.blue, end: Colors.red)
BorderRadiusTween(
begin: BorderRadius.circular(0),
end: BorderRadius.circular(32),
)
EdgeInsetsTween(
begin: EdgeInsets.zero,
end: const EdgeInsets.all(16),
)
TextStyleTween(
begin: const TextStyle(fontSize: 12),
end: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
)
// Position
Tween<Offset>(begin: const Offset(-1, 0), end: Offset.zero)
// Decoration
DecorationTween(
begin: BoxDecoration(color: Colors.blue, borderRadius: BorderRadius.zero),
end: BoxDecoration(color: Colors.red, borderRadius: BorderRadius.circular(16)),
)
CurvedAnimation — Easing Curves #
final curved = CurvedAnimation(
parent: _controller,
curve: Curves.easeInOut, // forward with a curve
reverseCurve: Curves.easeIn, // backward with a different curve (optional)
);
// Available curves:
Curves.linear // constant
Curves.easeIn // slow at the start, fast at the end
Curves.easeOut // fast at the start, slow at the end
Curves.easeInOut // slow at both start and end
Curves.bounceOut // bounce effect at the end
Curves.elasticOut // elastic/spring effect at the end
Curves.fastOutSlowIn // Material Design standard
Curves.decelerate // starts fast, slows down
AnimatedBuilder — Minimal Rebuild #
AnimatedBuilder lets you build animations without rebuilding the entire widget tree. This is very useful when the animation only affects a small part of the widget tree, because it prevents unnecessary rebuilds. If there’s a subtree that doesn’t depend on the animation in this builder function, that subtree is only built once and isn’t rebuilt on every animation tick.
class _ScaleAnimState extends State<ScaleWidget>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<double> _scaleAnim;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
_scaleAnim = Tween<double>(begin: 1.0, end: 1.2)
.animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut));
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTapDown: (_) => _controller.forward(),
onTapUp: (_) => _controller.reverse(),
child: AnimatedBuilder(
animation: _scaleAnim,
// The child here is not rebuilt every frame -- only once!
child: const Icon(Icons.favorite, size: 60, color: Colors.red),
builder: (context, child) {
return Transform.scale(
scale: _scaleAnim.value,
child: child, // reuse the already-built child
);
},
),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
}
Staggered Animation — Phased Animation #
The key element of staggered animation is this: there may be several animations, but they all need to be connected to one AnimationController. The solution is defining intervals in which the animation runs. For example, during the animation controller’s progress from 0.0 to 1.0, the first animation runs from 0.0 to 0.5 and the second animation runs from 0.5 to 1.0.
class _StaggeredState extends State<StaggeredWidget>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
// One controller, several animations with different intervals
late final Animation<double> _fadeAnim;
late final Animation<Offset> _slideAnim;
late final Animation<double> _scaleAnim;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 900),
vsync: this,
);
// Interval: the part of 0.0 - 1.0 where the animation is active
_fadeAnim = Tween<double>(begin: 0, end: 1).animate(
CurvedAnimation(
parent: _controller,
curve: const Interval(0.0, 0.4, curve: Curves.easeOut), // 0-40%
),
);
_slideAnim = Tween<Offset>(
begin: const Offset(0, 0.5),
end: Offset.zero,
).animate(
CurvedAnimation(
parent: _controller,
curve: const Interval(0.2, 0.7, curve: Curves.easeOut), // 20-70%
),
);
_scaleAnim = Tween<double>(begin: 0.8, end: 1.0).animate(
CurvedAnimation(
parent: _controller,
curve: const Interval(0.5, 1.0, curve: Curves.easeOut), // 50-100%
),
);
_controller.forward();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return FadeTransition(
opacity: _fadeAnim,
child: SlideTransition(
position: _slideAnim,
child: ScaleTransition(
scale: _scaleAnim,
child: child,
),
),
);
},
child: const Card(child: Text('Staggered!')),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
}
Hero Animation — Page Transitions #
Hero is a widget that makes a UI element “fly” from one page to another smoothly:
// On the LIST page
class ProductListItem extends StatelessWidget {
final Product product;
const ProductListItem({super.key, required this.product});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => ProductDetail(product: product)),
),
child: Hero(
// The tag must be UNIQUE and THE SAME on both pages
tag: 'product-image-${product.id}',
child: Image.network(product.imageUrl, width: 100, height: 100),
),
);
}
}
// On the DETAIL page
class ProductDetail extends StatelessWidget {
final Product product;
const ProductDetail({super.key, required this.product});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: [
Hero(
tag: 'product-image-${product.id}', // the same tag!
child: Image.network(
product.imageUrl,
width: double.infinity,
height: 300,
fit: BoxFit.cover,
),
),
Text(product.name),
Text('Rp ${product.price}'),
],
),
);
}
}
Transition Widgets — Ready-Made Explicit #
Flutter provides several Transition widgets that accept an Animation<double> — the partner of AnimationController:
// FadeTransition -- opacity
FadeTransition(
opacity: _controller, // or a tweened animation
child: const MyWidget(),
)
// SlideTransition -- position shift
SlideTransition(
position: Tween<Offset>(
begin: const Offset(-1, 0), // from the left of the screen
end: Offset.zero,
).animate(_controller),
child: const MyWidget(),
)
// ScaleTransition -- scale
ScaleTransition(
scale: _controller,
child: const MyWidget(),
)
// RotationTransition -- rotation
RotationTransition(
turns: _controller, // 1.0 = 360 degrees
child: const Icon(Icons.refresh),
)
// SizeTransition -- size
SizeTransition(
sizeFactor: _controller,
axis: Axis.vertical,
child: const MyWidget(),
)
Animation Performance Tips #
// 1. ALWAYS use child in AnimatedBuilder for static widgets
AnimatedBuilder(
animation: _animation,
child: const ExpensiveWidget(), // built only ONCE
builder: (context, child) {
return Transform.scale(
scale: _animation.value,
child: child, // reused every frame
);
},
)
// 2. Isolate the animated widget as small as possible
// WRONG: wrapping the whole screen
AnimatedBuilder(
animation: _anim,
builder: (context, _) => Column(
children: [
const HeavyStaticWidget(), // rebuilt every frame!
const AnotherHeavyWidget(), // rebuilt every frame!
Transform.scale(scale: _anim.value, child: const SmallWidget()),
],
),
)
// CORRECT: wrap only the changing part
Column(
children: [
const HeavyStaticWidget(), // not rebuilt
const AnotherHeavyWidget(), // not rebuilt
AnimatedBuilder(
animation: _anim,
builder: (context, child) =>
Transform.scale(scale: _anim.value, child: child),
child: const SmallWidget(), // not rebuilt
),
],
)
// 3. Use RepaintBoundary for animations inside lists
ListView.builder(
itemBuilder: (context, index) => RepaintBoundary(
child: AnimatedItem(item: items[index]),
),
)
// 4. ALWAYS dispose controllers
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Summary #
- Flutter divides animation into two categories: implicit (automatic, minimal setup) and explicit (full control, needs AnimationController).
- Use
AnimatedContainer,AnimatedOpacity,AnimatedSize,AnimatedSwitcherfor simple property animations — no controller needed.- Use
TweenAnimationBuilderfor custom implicit animations without creating your own controller.AnimationControlleris the animation engine — canforward(),reverse(),repeat(),stop(). Always use it with theSingleTickerProviderStateMixinorTickerProviderStateMixinmixin.Tweenmaps controller values (0.0-1.0) to the desired range — double, Color, Offset, TextStyle, etc.CurvedAnimationapplies easing curves —easeInOut,bounceOut,elasticOut, and others.AnimatedBuilderisolates rebuilds to only the parts affected by the animation. Use thechildparameter for widgets that don’t need rebuilding every frame.- Staggered animation uses one controller with several
Tweens, each wrapped in anInterval— enabling animations that run sequentially or overlap.Heromakes elements “fly” between pages — just wrap the same widget with an identical tag on both pages.