Animation #
Animasi bukan sekadar hiasan — ia adalah alat komunikasi yang memandu perhatian pengguna, memberikan feedback, dan membuat transisi terasa alami. Flutter menyediakan sistem animasi yang sangat lengkap, dari implicit animation yang butuh satu baris kode, hingga explicit animation yang memberikan kontrol penuh atas setiap frame. Memahami keduanya memungkinkan kamu memilih alat yang tepat untuk setiap situasi.
Dua Kategori Animasi Flutter #
IMPLICIT ANIMATION (sederhana):
✓ Animasi properti tunggal (ukuran, warna, opacity)
✓ Set nilai target, Flutter mengurus transisi
✓ Tidak perlu AnimationController
✓ Contoh: AnimatedContainer, AnimatedOpacity, TweenAnimationBuilder
EXPLICIT ANIMATION (fleksibel):
✓ Kontrol penuh atas timing, playback, dan lifecycle
✓ Animasi berulang, terbalik, atau triggered oleh event
✓ Perlu AnimationController
✓ Contoh: AnimatedBuilder, AnimatedWidget, transisi kustom
Panduan memilih: gunakan implicit animation jika sebuah built-in widget sudah memenuhi kebutuhan. Gunakan TweenAnimationBuilder untuk animasi kustom tanpa controller. Gunakan explicit animation jika perlu kontrol lifecycle penuh.
Implicit Animation — Animasi Otomatis #
AnimatedContainer #
AnimatedContainer adalah versi animasi dari Container — secara otomatis menginterpolasi antar nilai property saat berubah:
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,
// Semua property ini dianimasikan saat berubah
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),
),
);
}
}
Widget Implicit Animation Lainnya #
// AnimatedOpacity -- fade in/out
AnimatedOpacity(
opacity: _isVisible ? 1.0 : 0.0,
duration: const Duration(milliseconds: 200),
child: const Text('Teks yang bisa hilang'),
)
// AnimatedSize -- ukuran berubah secara smooth
AnimatedSize(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
child: _isExpanded
? const LargeContent()
: const SmallContent(),
)
// AnimatedSwitcher -- fade antara dua widget berbeda
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-fade dua widget
AnimatedCrossFade(
duration: const Duration(milliseconds: 300),
crossFadeState: _showFirst
? CrossFadeState.showFirst
: CrossFadeState.showSecond,
firstChild: const LoginForm(),
secondChild: const RegisterForm(),
)
// AnimatedPositioned -- posisi di dalam Stack
Stack(
children: [
AnimatedPositioned(
duration: const Duration(milliseconds: 400),
curve: Curves.bounceOut,
left: _atLeft ? 0 : 200,
top: _atTop ? 0 : 300,
child: const FloatingButton(),
),
],
)
TweenAnimationBuilder — Implicit Kustom #
TweenAnimationBuilder mengurangi kompleksitas, memudahkan animasi properti tanpa memerlukan AnimationController. Ini berguna untuk animasi sederhana yang membutuhkan setup minimal.
// Animasi nilai numerik kustom
TweenAnimationBuilder<double>(
tween: Tween<double>(begin: 0, end: _targetValue),
duration: const Duration(milliseconds: 600),
curve: Curves.easeOut,
builder: (context, value, child) {
return Column(
children: [
// Gunakan value untuk render
LinearProgressIndicator(value: value / 100),
Text('${value.toInt()}%'),
child!, // child tidak di-rebuild setiap frame
],
);
},
child: const Text('Progress'), // statis -- bukan bagian dari animasi
)
// Animasi Color
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),
);
},
)
Fondasi Explicit Animation #
AnimationController — Mesin Animasi #
AnimationController bisa dianggap sebagai mesin yang menghasilkan nilai-nilai berurutan untuk animasi kapan pun perangkat siap. Controller harus mengetahui rentang nilai dan durasinya terlebih dahulu. Karena ini adalah mesin, kita juga bisa membuatnya berhenti, membalik, mengulang, dan mereset.
class _AnimatedScreenState extends State<AnimatedScreen>
with SingleTickerProviderStateMixin { // mixin wajib untuk satu controller
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 500),
vsync: this, // this = TickerProvider dari mixin
);
}
// Kontrol playback
void _play() => _controller.forward();
void _reverse() => _controller.reverse();
void _repeat() => _controller.repeat(reverse: true);
void _stop() => _controller.stop();
void _reset() => _controller.reset();
// Dengarkan status animasi
void _listenStatus() {
_controller.addStatusListener((status) {
if (status == AnimationStatus.completed) {
print('Animasi selesai maju');
} else if (status == AnimationStatus.dismissed) {
print('Animasi selesai mundur');
}
});
}
@override
void dispose() {
_controller.dispose(); // WAJIB!
super.dispose();
}
}
// Untuk beberapa controller sekaligus:
class _MultiAnimState extends State<MultiAnim>
with TickerProviderStateMixin { // bukan Single-
late final AnimationController _controller1;
late final AnimationController _controller2;
}
Tween — Interpolasi Nilai #
Tween adalah objek stateless yang hanya menerima nilai awal dan akhir. Satu-satunya tugas Tween adalah mendefinisikan pemetaan dari rentang input ke rentang output. Rentang input umumnya 0.0 hingga 1.0, tapi bukan suatu keharusan.
// Tween dasar
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 tween ke controller
final sizeAnimation = sizeTween.animate(_controller);
// Atau langsung dengan CurvedAnimation untuk kurva:
final curvedAnimation = CurvedAnimation(
parent: _controller,
curve: Curves.easeInOut,
);
final sizeAnimation = sizeTween.animate(curvedAnimation);
// Gunakan .value di dalam builder
AnimatedBuilder(
animation: sizeAnimation,
builder: (context, _) {
return SizedBox(
width: sizeAnimation.value,
height: sizeAnimation.value,
);
},
)
Jenis-jenis Tween #
// Numerik
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),
)
// Posisi
Tween<Offset>(begin: const Offset(-1, 0), end: Offset.zero)
// Dekorasi
DecorationTween(
begin: BoxDecoration(color: Colors.blue, borderRadius: BorderRadius.zero),
end: BoxDecoration(color: Colors.red, borderRadius: BorderRadius.circular(16)),
)
CurvedAnimation — Kurva Easing #
final curved = CurvedAnimation(
parent: _controller,
curve: Curves.easeInOut, // maju dengan kurva
reverseCurve: Curves.easeIn, // mundur dengan kurva berbeda (opsional)
);
// Kurva yang tersedia:
Curves.linear // konstan
Curves.easeIn // lambat di awal, cepat di akhir
Curves.easeOut // cepat di awal, lambat di akhir
Curves.easeInOut // lambat di awal dan akhir
Curves.bounceOut // efek pantulan di akhir
Curves.elasticOut // efek elastis/spring di akhir
Curves.fastOutSlowIn // Material Design standard
Curves.decelerate // mulai cepat, melambat
AnimatedBuilder — Rebuild Minimal #
AnimatedBuilder memungkinkan kamu membangun animasi tanpa harus me-rebuild seluruh widget tree. Ini sangat berguna ketika animasi hanya memengaruhi sebagian kecil widget tree, karena mencegah rebuild yang tidak perlu. Jika ada subtree yang tidak bergantung pada animasi di builder function ini, subtree tersebut hanya akan dibangun sekali dan tidak di-rebuild setiap tick animasi.
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,
// child di sini tidak di-rebuild setiap frame -- hanya sekali!
child: const Icon(Icons.favorite, size: 60, color: Colors.red),
builder: (context, child) {
return Transform.scale(
scale: _scaleAnim.value,
child: child, // re-use child yang sudah di-build
);
},
),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
}
Staggered Animation — Animasi Bertahap #
Elemen kunci dari staggered animation adalah ini: mungkin ada beberapa animasi tetapi semuanya perlu terhubung ke satu AnimationController. Solusinya adalah mendefinisikan interval di mana animasi berjalan. Misalnya, selama progres animation controller dari 0.0 hingga 1.0, animasi pertama berjalan dari 0.0 hingga 0.5 dan animasi kedua berjalan dari 0.5 hingga 1.0.
class _StaggeredState extends State<StaggeredWidget>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
// Satu controller, beberapa animasi dengan interval berbeda
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: bagian dari 0.0 - 1.0 di mana animasi aktif
_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 — Transisi Antar Halaman #
Hero adalah widget yang membuat elemen UI “terbang” dari satu halaman ke halaman lain dengan mulus:
// Di halaman LIST
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(
// Tag harus UNIK dan SAMA di kedua halaman
tag: 'product-image-${product.id}',
child: Image.network(product.imageUrl, width: 100, height: 100),
),
);
}
}
// Di halaman DETAIL
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}', // tag yang sama!
child: Image.network(
product.imageUrl,
width: double.infinity,
height: 300,
fit: BoxFit.cover,
),
),
Text(product.nama),
Text('Rp ${product.harga}'),
],
),
);
}
}
Transition Widgets — Explicit Siap Pakai #
Flutter menyediakan sejumlah widget Transition yang menerima Animation<double> — pasangan dari AnimationController:
// FadeTransition -- opacity
FadeTransition(
opacity: _controller, // atau animation yang sudah di-tween
child: const MyWidget(),
)
// SlideTransition -- pergeseran posisi
SlideTransition(
position: Tween<Offset>(
begin: const Offset(-1, 0), // dari kiri layar
end: Offset.zero,
).animate(_controller),
child: const MyWidget(),
)
// ScaleTransition -- skala
ScaleTransition(
scale: _controller,
child: const MyWidget(),
)
// RotationTransition -- rotasi
RotationTransition(
turns: _controller, // 1.0 = 360 derajat
child: const Icon(Icons.refresh),
)
// SizeTransition -- ukuran
SizeTransition(
sizeFactor: _controller,
axis: Axis.vertical,
child: const MyWidget(),
)
Tips Performa Animasi #
// 1. SELALU gunakan child di AnimatedBuilder untuk widget statis
AnimatedBuilder(
animation: _animation,
child: const ExpensiveWidget(), // hanya di-build SEKALI
builder: (context, child) {
return Transform.scale(
scale: _animation.value,
child: child, // di-reuse setiap frame
);
},
)
// 2. Isolasi widget yang dianimasikan sekecil mungkin
// SALAH: wrap seluruh screen
AnimatedBuilder(
animation: _anim,
builder: (context, _) => Column(
children: [
const HeavyStaticWidget(), // rebuild setiap frame!
const AnotherHeavyWidget(), // rebuild setiap frame!
Transform.scale(scale: _anim.value, child: const SmallWidget()),
],
),
)
// BENAR: wrap hanya bagian yang berubah
Column(
children: [
const HeavyStaticWidget(), // tidak rebuild
const AnotherHeavyWidget(), // tidak rebuild
AnimatedBuilder(
animation: _anim,
builder: (context, child) =>
Transform.scale(scale: _anim.value, child: child),
child: const SmallWidget(), // tidak rebuild
),
],
)
// 3. Gunakan RepaintBoundary untuk animasi di dalam list
ListView.builder(
itemBuilder: (context, index) => RepaintBoundary(
child: AnimatedItem(item: items[index]),
),
)
// 4. Dispose controller SELALU
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Ringkasan #
- Flutter membagi animasi menjadi dua kategori: implicit (otomatis, minimal setup) dan explicit (kontrol penuh, perlu AnimationController).
- Gunakan
AnimatedContainer,AnimatedOpacity,AnimatedSize,AnimatedSwitcheruntuk animasi properti sederhana — tidak perlu controller.- Gunakan
TweenAnimationBuilderuntuk animasi implicit kustom tanpa harus membuat controller sendiri.AnimationControlleradalah mesin animasi — bisaforward(),reverse(),repeat(),stop(). Selalu gunakan dengan mixinSingleTickerProviderStateMixinatauTickerProviderStateMixin.Tweenmemetakan nilai controller (0.0-1.0) ke rentang yang diinginkan — double, Color, Offset, TextStyle, dll.CurvedAnimationmenerapkan kurva easing —easeInOut,bounceOut,elasticOut, dan lainnya.AnimatedBuildermengisolasi rebuild hanya pada bagian yang dipengaruhi animasi. Gunakan parameterchilduntuk widget yang tidak perlu di-rebuild setiap frame.- Staggered animation menggunakan satu controller dengan beberapa
Tweenyang masing-masing dibungkusInterval— memungkinkan animasi yang berjalan berurutan atau bertumpang tindih.Heromembuat elemen “terbang” antar halaman — cukup bungkus widget yang sama dengan tag yang identik di kedua halaman.