Pengantar
Animasi membuat UI hidup, responsif, dan profesional. Flutter memiliki dua pendekatan:
- Implicit Animations: Anda ubah properti, Flutter yang menganimasikan transisi.
- Explicit Animations: Anda kontrol penuh — durasi, curve, arah, status.
| Skenario | Pendekatan |
|---|---|
| Hover, color/size change | Implicit |
| Shared element (Hero) | Implicit / Explicit |
| Custom spinner | Explicit |
| Multi-step animation | Explicit (Staggered) |
| Drag gesture | Explicit |
Rule of thumb: Mulai dengan implicit. Jika perlu repeat/reverse/status callback, beralih ke explicit.
1. AnimatedContainer
Otomatis menganimasikan properti apapun saat diubah via setState.
import 'package:flutter/material.dart';
class AnimatedBox extends StatefulWidget {
const AnimatedBox({super.key});
@override
State<AnimatedBox> createState() => _AnimatedBoxState();
}
class _AnimatedBoxState extends State<AnimatedBox> {
bool _expanded = false;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('AnimatedContainer')),
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 400),
curve: Curves.easeInOutCubic,
width: _expanded ? 280 : 120,
height: _expanded ? 200 : 120,
decoration: BoxDecoration(
color: _expanded ? Colors.deepPurple : Colors.amber,
borderRadius: BorderRadius.circular(_expanded ? 24 : 60),
),
child: Center(
child: Text(
_expanded ? 'Expanded' : 'Tap Me',
style: TextStyle(
color: Colors.white,
fontSize: _expanded ? 24 : 18,
),
),
),
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () => setState(() => _expanded = !_expanded),
child: Text(_expanded ? 'Collapse' : 'Expand'),
),
],
),
),
);
}
}Properti animatable: width, height, color, decoration, borderRadius, margin, padding, transform.
2. AnimatedOpacity
AnimatedOpacity(
opacity: _visible ? 1.0 : 0.0,
duration: const Duration(milliseconds: 500),
child: Container(width: 200, height: 200, color: Colors.teal),
)Performance:
AnimatedOpacitylebih efisien daripadaOpacitywidget.
3. AnimatedAlign / AnimatedPadding / AnimatedPositioned
AnimatedAlign(
alignment: _alignment,
duration: const Duration(milliseconds: 600),
curve: Curves.easeInOutBack,
child: Container(width: 80, height: 80, color: Colors.indigo),
)4. TweenAnimationBuilder — Custom Tween
TweenAnimationBuilder<Color?>(
tween: ColorTween(begin: Colors.orange, end: Colors.purple),
duration: const Duration(seconds: 2),
curve: Curves.easeInOut,
builder: (context, color, child) {
return Container(
width: 200, height: 200,
color: color,
child: child,
);
},
child: const Center(
child: Text('Color Tween', style: TextStyle(color: Colors.white)),
),
)Tentukan Tween + builder untuk kustomisasi penuh.
5. Curve Types
Curves.easeInOut // lambat-cepat-lambat
Curves.easeIn // lambat ke cepat
Curves.easeOut // cepat ke lambat
Curves.bounceOut // overshoot + pantul
Curves.elasticOut // overshoot + osilasi
Curves.fastOutSlowIn // Material default6. Hero — Shared Element Transition
// Screen 1
Hero(
tag: 'product_${product.id}',
child: Image.network(product.imageUrl, width: 50, height: 50),
)
// Screen 2 (tag harus SAMA)
Hero(
tag: 'product_${product.id}',
child: Image.network(product.imageUrl, width: double.infinity, height: 300),
)Aturan: Tag unique dalam satu route. Hero harus ada di kedua screen. Flight hanya saat push/pop.
7. AnimationController — Explicit Animation
class ControllerDemo extends StatefulWidget {
const ControllerDemo({super.key});
@override
State<ControllerDemo> createState() => _ControllerDemoState();
}
class _ControllerDemoState extends State<ControllerDemo>
with SingleTickerProviderStateMixin {
late final AnimationController _ctrl;
late final Animation<double> _anim;
@override
void initState() {
super.initState();
_ctrl = AnimationController(
vsync: this,
duration: const Duration(seconds: 2),
);
_anim = Tween<double>(begin: 0, end: 300).animate(
CurvedAnimation(parent: _ctrl, curve: Curves.easeInOut),
);
}
@override
void dispose() {
_ctrl.dispose(); // WAJIB
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('AnimationController')),
body: Center(
child: AnimatedBuilder(
animation: _anim,
builder: (context, child) => Container(
width: _anim.value, height: _anim.value,
decoration: BoxDecoration(
color: Colors.amber,
borderRadius: BorderRadius.circular(16),
),
child: child,
),
child: const Center(
child: Text('Animated', style: TextStyle(color: Colors.white)),
),
),
),
floatingActionButton: Row(
mainAxisSize: MainAxisSize.min,
children: [
FloatingActionButton(onPressed: () => _ctrl.forward(),
child: const Icon(Icons.play_arrow)),
const SizedBox(width: 4),
FloatingActionButton(onPressed: () => _ctrl.reverse(),
child: const Icon(Icons.replay)),
const SizedBox(width: 4),
FloatingActionButton(onPressed: () => _ctrl.repeat(reverse: true),
child: const Icon(Icons.loop)),
const SizedBox(width: 4),
FloatingActionButton(onPressed: () => _ctrl.stop(),
child: const Icon(Icons.stop)),
],
),
);
}
}vsync
SingleTickerProviderStateMixin = 1 controller, TickerProviderStateMixin = multiple:
class MultiCtrl extends State<MyWidget> with TickerProviderStateMixin {
late final _c1 = AnimationController(vsync: this, duration: Duration(seconds: 1));
late final _c2 = AnimationController(vsync: this, duration: Duration(seconds: 2));
}8. Tween Types + Chaining
final sizeTween = Tween<Size>(begin: Size(100, 100), end: Size(200, 300));
final colorTween = ColorTween(begin: Colors.blue, end: Colors.red);
final offsetTween = Tween<Offset>(begin: Offset.zero, end: Offset(100, 200));
final curved = CurvedAnimation(
parent: controller,
curve: Curves.easeInOutCubic,
reverseCurve: Curves.easeOutCubic, // opsional
);Chaining Multiple Animations
class ChainAnim extends StatefulWidget {
const ChainAnim({super.key});
@override
State<ChainAnim> createState() => _ChainAnimState();
}
class _ChainAnimState extends State<ChainAnim>
with SingleTickerProviderStateMixin {
late final AnimationController _ctrl;
late final Animation<Offset> _slide;
late final Animation<double> _fade;
late final Animation<double> _scale;
@override
void initState() {
super.initState();
_ctrl = AnimationController(vsync: this, duration: const Duration(seconds: 2));
_slide = Tween<Offset>(begin: const Offset(0, -1), end: Offset.zero)
.animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOut));
_fade = Tween<double>(begin: 0, end: 1)
.animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeIn));
_scale = Tween<double>(begin: 0.5, end: 1)
.animate(CurvedAnimation(parent: _ctrl, curve: Curves.elasticOut));
}
@override
void dispose() { _ctrl.dispose(); super.dispose(); }
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Chain')),
body: Center(
child: AnimatedBuilder(
animation: _ctrl,
builder: (context, _) => Transform.scale(
scale: _scale.value,
child: SlideTransition(
position: _slide,
child: FadeTransition(
opacity: _fade,
child: Container(width: 200, height: 200,
decoration: BoxDecoration(
color: Colors.deepOrange,
borderRadius: BorderRadius.circular(20),
),
),
),
),
),
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => _ctrl.isCompleted ? _ctrl.reverse() : _ctrl.forward(),
child: Icon(_ctrl.isCompleted ? Icons.replay : Icons.play_arrow),
),
);
}
}9. addListener + addStatusListener
// Setiap frame
_animation.addListener(() { /* akses _animation.value */ });
// Status berubah
_controller.addStatusListener((AnimationStatus status) {
switch (status) {
case AnimationStatus.completed:
_controller.reverse(); // auto reverse
case AnimationStatus.dismissed:
_controller.forward(); // auto forward (loop)
default: break;
}
});
// Loop patterns
_controller.repeat();
_controller.repeat(reverse: true);Gunakan
AnimatedBuilderdaripadaaddListener+setState. AnimatedBuilder hanya rebuild widget dalam builder, bukan seluruh tree.
10. Staggered Animation — Cascade Multi-Step
Gunakan Interval(0.0, 0.4) untuk menentukan kapan animasi jalan dalam rentang durasi.
class StaggeredAnim extends StatefulWidget {
const StaggeredAnim({super.key});
@override
State<StaggeredAnim> createState() => _StaggeredAnimState();
}
class _StaggeredAnimState extends State<StaggeredAnim>
with SingleTickerProviderStateMixin {
late final AnimationController _ctrl;
late final Animation<Offset> _slide1;
late final Animation<Offset> _slide2;
late final Animation<double> _scale;
@override
void initState() {
super.initState();
_ctrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 2000));
_slide1 = Tween<Offset>(begin: const Offset(-1, 0), end: Offset.zero).animate(
CurvedAnimation(parent: _ctrl,
curve: const Interval(0.0, 0.4, curve: Curves.easeOut)));
_slide2 = Tween<Offset>(begin: const Offset(1, 0), end: Offset.zero).animate(
CurvedAnimation(parent: _ctrl,
curve: const Interval(0.2, 0.6, curve: Curves.easeOut)));
_scale = Tween<double>(begin: 0, end: 1).animate(
CurvedAnimation(parent: _ctrl,
curve: const Interval(0.5, 1.0, curve: Curves.elasticOut)));
}
@override
void dispose() { _ctrl.dispose(); super.dispose(); }
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Staggered')),
body: Center(
child: Column(mainAxisSize: MainAxisSize.min, children: [
SlideTransition(position: _slide1, child: _box('Item 1', Colors.blue)),
const SizedBox(height: 12),
SlideTransition(position: _slide2, child: _box('Item 2', Colors.green)),
const SizedBox(height: 12),
AnimatedBuilder(
animation: _ctrl,
builder: (context, child) =>
Transform.scale(scale: _scale.value, child: child),
child: _box('Item 3', Colors.orange),
),
]),
),
floatingActionButton: FloatingActionButton(
onPressed: () => _ctrl.isCompleted ? _ctrl.reverse() : _ctrl.forward(),
child: Icon(_ctrl.isCompleted ? Icons.replay : Icons.play_arrow),
),
);
}
Widget _box(String t, Color c) => Container(width: 200, height: 60,
color: c, alignment: Alignment.center,
child: Text(t, style: const TextStyle(color: Colors.white, fontSize: 18)));
}List Staggered Entry
ListView.builder(
itemCount: 5,
itemBuilder: (context, i) {
final delay = i / 5;
return SlideTransition(
position: Tween<Offset>(begin: const Offset(1, 0), end: Offset.zero).animate(
CurvedAnimation(parent: _controller,
curve: Interval(delay, delay + 0.2, curve: Curves.easeOut)),
),
child: Card(child: ListTile(title: Text('Item ${i + 1}'))),
);
},
)11. Performance Tips
// 1. Gunakan RepaintBoundary
RepaintBoundary(
child: AnimatedBuilder(animation: animation, builder: ...),
)
// 2. Hindari animasi layout — pakai transform
Transform.translate(offset: Offset(value, 0), child: Container(width: 100))
// 3. AnimatedBuilder > addListener + setState
// AnimatedBuilder hanya rebuild widget dalam builder
// 4. Dispose AnimationController di dispose()
@override
void dispose() { _controller.dispose(); super.dispose(); }12. Common Animation Patterns
Pulse Effect
class PulseAnim extends StatefulWidget {
final Widget child;
const PulseAnim({super.key, required this.child});
@override
State<PulseAnim> createState() => _PulseAnimState();
}
class _PulseAnimState extends State<PulseAnim>
with SingleTickerProviderStateMixin {
late final AnimationController _ctrl;
late final Animation<double> _anim;
@override
void initState() {
super.initState();
_ctrl = AnimationController(vsync: this,
duration: const Duration(milliseconds: 1200))..repeat(reverse: true);
_anim = Tween<double>(begin: 0.95, end: 1.05)
.animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeInOut));
}
@override
void dispose() { _ctrl.dispose(); super.dispose(); }
@override
Widget build(BuildContext context) => AnimatedBuilder(
animation: _anim,
builder: (context, child) =>
Transform.scale(scale: _anim.value, child: child),
child: widget.child,
);
}Shake Effect
class ShakeAnim extends StatefulWidget {
final Widget child;
const ShakeAnim({super.key, required this.child});
@override
State<ShakeAnim> createState() => _ShakeAnimState();
}
class _ShakeAnimState extends State<ShakeAnim>
with SingleTickerProviderStateMixin {
late final AnimationController _ctrl;
late final Animation<Offset> _anim;
@override
void initState() {
super.initState();
_ctrl = AnimationController(vsync: this,
duration: const Duration(milliseconds: 400));
_anim = TweenSequence<Offset>([
TweenSequenceItem(
tween: Tween(begin: Offset.zero, end: const Offset(-10, 0)), weight: 1),
TweenSequenceItem(
tween: Tween(begin: const Offset(-10, 0), end: const Offset(10, 0)), weight: 1),
TweenSequenceItem(
tween: Tween(begin: const Offset(10, 0), end: Offset.zero), weight: 1),
]).animate(_ctrl);
}
@override
void dispose() { _ctrl.dispose(); super.dispose(); }
void shake() => _ctrl.forward();
@override
Widget build(BuildContext context) => GestureDetector(
onTap: shake,
child: SlideTransition(position: _anim, child: widget.child),
);
}Loading Dots
class LoadingDots extends StatefulWidget {
const LoadingDots({super.key});
@override
State<LoadingDots> createState() => _LoadingDotsState();
}
class _LoadingDotsState extends State<LoadingDots>
with SingleTickerProviderStateMixin {
late final AnimationController _ctrl;
@override
void initState() {
super.initState();
_ctrl = AnimationController(vsync: this,
duration: const Duration(milliseconds: 1200))..repeat(reverse: true);
}
@override
void dispose() { _ctrl.dispose(); super.dispose(); }
@override
Widget build(BuildContext context) => Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(3, (i) => AnimatedBuilder(
animation: _ctrl,
builder: (context, _) {
final v = ((_ctrl.value - i * 0.15) % 1.0).clamp(0.0, 1.0);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Container(
width: 8 + v * 12, height: 8 + v * 12,
decoration: BoxDecoration(
color: Colors.blue.withOpacity(0.3 + v * 0.7),
shape: BoxShape.circle,
),
),
);
},
)),
);
}Common Mistakes
- Lupa dispose AnimationController — Memory leak paling umum. Selalu panggil di
dispose(). - setState di addListener — Pakai
AnimatedBuilderyang hanya rebuild widget spesifik. - Tidak pakai RepaintBoundary — Animasi kompleks sebabkan layout shock.
- Terlalu banyak properti dianimasikan — Pilih 2-3 properti paling penting.
- Hero tag tidak unique — Prefix seperti
'product_${product.id}'. - Implicit untuk animasi kompleks — Migrasi ke explicit saat butuh repeat/reverse.
Summary
| Animasi | Widget | Use Case |
|---|---|---|
| Hover/Tap | AnimatedContainer | Button, Card |
| Fade | AnimatedOpacity | Loading, visibility |
| Page transition | Hero | Navigation |
| Loading | AnimationController.repeat() | Spinner |
| List enter | Staggered | List items |
| Gesture-driven | AnimationController + GestureDetector | Swipe, drag |
Apa Selanjutnya?
- Architecture Patterns — Riverpod, Bloc, Clean Architecture
- CustomPainter — Animasi kustom dengan canvas
- Rive / Lottie — Animasi dari design tools
- Navigation 2.0 — Router + animated transitions