1. Pengenalan Animasi di Flutter
Animasi adalah elemen kunci untuk menciptakan pengalaman pengguna yang menarik dan responsif. Flutter menyediakan sistem animasi yang sangat powerful dan fleksibel β mulai dari implicit animations yang sederhana (cukup ubah nilai, animasi berjalan otomatis) hingga explicit animations yang memberikan kontrol penuh atas setiap frame.
Jenis Animasi di Flutter
| Jenis | Kontrol | Kompleksitas | Contoh Widget |
|---|---|---|---|
| Implicit (Implisit) | Otomatis saat nilai berubah | π’ Mudah | AnimatedContainer, AnimatedOpacity |
| Explicit (Eksplisit) | Full kontrol via AnimationController | π‘ Sedang | AnimatedBuilder, AnimatedWidget |
| Hero | Otomatis antar halaman | π’ Mudah | Hero widget |
| Custom Transition | Full kontrol page route | π΄ Lanjut | PageRouteBuilder |
| Physics-based | Spring/damping simulation | π‘ Sedang | SpringSimulation, GestureDetector |
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β ANIMATION PIPELINE β β β β ββββββββββββββββ ββββββββββββββββ βββββββββββββ β β β Animation βββββΆβ Tween βββββΆβ Curved β β β β Controller β β (range) β β Animation β β β β 0.0 β 1.0 β β 0.0 β 200.0 β β (easing) β β β ββββββββ¬ββββββββ ββββββββββββββββ βββββββ¬ββββββ β β β β β β βΌ βΌ β β βββββββββββββββββββββββββββββββββββββββββββββββββββ β β β AnimatedBuilder / AnimatedWidget β β β β β Rebuild UI setiap frame (~60fps) β β β βββββββββββββββββββββββββ¬ββββββββββββββββββββββββββ β β βΌ β β βββββββββββββββββββββββββββββββββββββββββββββββββββ β β β Widget Tree (UI di-render ulang) β β β βββββββββββββββββββββββββββββββββββββββββββββββββββ β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
2. Implicit Animations (Animasi Implisit)
Implicit animations adalah jenis animasi termudah di Flutter. Cukup ubah nilai properti di dalam widget animasi, dan Flutter akan secara otomatis menganimasikan perubahan tersebut. Tidak perlu AnimationController atau setup khusus β Flutter menangani semuanya.
import 'package:flutter/material.dart';
class AnimatedContainerDemo extends StatefulWidget {
@override
_AnimatedContainerDemoState createState() => _AnimatedContainerDemoState();
}
class _AnimatedContainerDemoState extends State<AnimatedContainerDemo> {
bool _expanded = false;
bool _isCircle = false;
Color _color = Colors.blue;
double _width = 100;
double _height = 100;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('AnimatedContainer')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// AnimatedContainer β Animasi otomatis saat properti berubah
AnimatedContainer(
duration: Duration(milliseconds: 500),
curve: Curves.easeInOut,
width: _width,
height: _height,
decoration: BoxDecoration(
color: _color,
borderRadius: BorderRadius.circular(
_isCircle ? 100 : 16,
),
boxShadow: [
BoxShadow(
color: _color.withOpacity(0.4),
blurRadius: _expanded ? 20 : 8,
offset: Offset(0, _expanded ? 8 : 4),
),
],
),
child: Center(
child: Icon(
_isCircle ? Icons.star : Icons.favorite,
color: Colors.white,
size: _expanded ? 48 : 32,
),
),
),
SizedBox(height: 32),
// Toggle buttons
Wrap(
spacing: 8,
children: [
ElevatedButton(
onPressed: () {
setState(() {
_expanded = !_expanded;
_width = _expanded ? 200 : 100;
_height = _expanded ? 200 : 100;
_color = _expanded ? Colors.purple : Colors.blue;
});
},
child: Text(_expanded ? 'Kecilkan' : 'Perbesar'),
),
ElevatedButton(
onPressed: () {
setState(() {
_isCircle = !_isCircle;
});
},
child: Text(_isCircle ? 'Kotak' : 'Lingkaran'),
),
],
),
],
),
),
);
}
}
// ===== ANIMATED OPACITY =====
class AnimatedOpacityDemo extends StatefulWidget {
@override
_AnimatedOpacityDemoState createState() => _AnimatedOpacityDemoState();
}
class _AnimatedOpacityDemoState extends State<AnimatedOpacityDemo> {
double _opacity = 1.0;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AnimatedOpacity(
opacity: _opacity,
duration: Duration(milliseconds: 800),
curve: Curves.easeOut,
child: Container(
width: 200,
height: 200,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [Colors.blue, Colors.purple],
),
borderRadius: BorderRadius.circular(16),
),
child: Center(
child: Text(
'Flutter',
style: TextStyle(
color: Colors.white,
fontSize: 28,
fontWeight: FontWeight.bold,
),
),
),
),
),
SizedBox(height: 20),
Slider(
value: _opacity,
onChanged: (value) => setState(() => _opacity = value),
min: 0.0,
max: 1.0,
),
],
),
),
);
}
}
// ===== ANIMATED POSITIONED (dalam Stack) =====
class AnimatedPositionedDemo extends StatefulWidget {
@override
_AnimatedPositionedDemoState createState() => _AnimatedPositionedDemoState();
}
class _AnimatedPositionedDemoState extends State<AnimatedPositionedDemo> {
bool _moved = false;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(
children: [
AnimatedPositioned(
duration: Duration(milliseconds: 600),
curve: Curves.elasticOut,
left: _moved ? 250 : 20,
top: _moved ? 400 : 100,
child: GestureDetector(
onTap: () => setState(() => _moved = !_moved),
child: Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: Colors.orange,
shape: BoxShape.circle,
),
child: Icon(Icons.touch_app, color: Colors.white),
),
),
),
],
),
);
}
}
// ===== ANIMATED CROSSFADE =====
class AnimatedCrossFadeDemo extends StatefulWidget {
@override
_AnimatedCrossFadeDemoState createState() => _AnimatedCrossFadeDemoState();
}
class _AnimatedCrossFadeDemoState extends State<AnimatedCrossFadeDemo> {
bool _showFirst = true;
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AnimatedCrossFade(
firstChild: Container(
width: 200,
height: 200,
color: Colors.blue,
child: Center(child: Text('Widget 1', style: TextStyle(color: Colors.white, fontSize: 20))),
),
secondChild: Container(
width: 200,
height: 200,
color: Colors.red,
child: Center(child: Text('Widget 2', style: TextStyle(color: Colors.white, fontSize: 20))),
),
crossFadeState: _showFirst ? CrossFadeState.showFirst : CrossFadeState.showSecond,
duration: Duration(milliseconds: 500),
),
SizedBox(height: 20),
ElevatedButton(
onPressed: () => setState(() => _showFirst = !_showFirst),
child: Text('Toggle'),
),
],
);
}
}
3. Animated Widget Lengkap
Daftar Implicit Animation Widgets
| Widget | Yang Dianimasikan | Penggunaan Umum |
|---|---|---|
| AnimatedContainer | Size, color, padding, margin, decoration | Kartu yang berubah ukuran/warna |
| AnimatedOpacity | Transparansi (0.0-1.0) | Fade in/out konten |
| AnimatedPositioned | Posisi dalam Stack | Menggeser elemen |
| AnimatedPadding | Padding | Spacing berubah |
| AnimatedAlign | Alignment | Memindahkan elemen ke posisi baru |
| AnimatedDefaultTextStyle | Style teks | Perubahan font/warna teks |
| AnimatedPhysicalModel | Elevation, shadow, shape | Card elevation animation |
| AnimatedRotation | Rotasi (derajat) | Icon yang berputar |
| AnimatedScale | Skala | Zoom in/out |
| AnimatedSlide | Offset (posisi relatif) | Slide in/out |
| AnimatedSwitcher | Switch antar child widget | Ganti konten dengan animasi |
| AnimatedCrossFade | Cross-fade antar dua child | Toggle antar widget |
| TweenAnimationBuilder | Properti apapun via Tween | Animasi custom tanpa controller |
// TweenAnimationBuilder β Implicit animation untuk properti custom
class TweenAnimationDemo extends StatefulWidget {
@override
_TweenAnimationDemoState createState() => _TweenAnimationDemoState();
}
class _TweenAnimationDemoState extends State<TweenAnimationDemo> {
double _targetValue = 0;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Animate number counter
TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: _targetValue),
duration: Duration(seconds: 2),
curve: Curves.easeOutCubic,
builder: (context, value, child) {
return Text(
'${value.toStringAsFixed(0)}%',
style: TextStyle(
fontSize: 72,
fontWeight: FontWeight.bold,
color: Colors.blue,
),
);
},
),
SizedBox(height: 20),
// Animate gradient rotation
TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: _targetValue / 100),
duration: Duration(milliseconds: 800),
builder: (context, value, child) {
return Container(
width: 150,
height: 150,
decoration: BoxDecoration(
gradient: SweepGradient(
colors: [
Colors.blue,
Colors.purple,
Colors.blue,
],
stops: [0, value, 1],
),
shape: BoxShape.circle,
),
child: Center(
child: Text(
'${(value * 100).toInt()}%',
style: TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
),
);
},
),
SizedBox(height: 30),
// Slider
Slider(
value: _targetValue,
onChanged: (v) => setState(() => _targetValue = v),
min: 0,
max: 100,
divisions: 10,
),
],
),
),
);
}
}
// AnimatedSwitcher β Animasi pergantian widget
class AnimatedSwitcherDemo extends StatefulWidget {
@override
_AnimatedSwitcherDemoState createState() => _AnimatedSwitcherDemoState();
}
class _AnimatedSwitcherDemoState extends State<AnimatedSwitcherDemo> {
int _count = 0;
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AnimatedSwitcher(
duration: Duration(milliseconds: 400),
transitionBuilder: (child, animation) {
return ScaleTransition(
scale: animation,
child: child,
);
},
child: Text(
'$_count',
key: ValueKey(_count), // Key penting untuk AnimatedSwitcher!
style: TextStyle(fontSize: 80, fontWeight: FontWeight.bold),
),
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
icon: Icon(Icons.remove, size: 32),
onPressed: () => setState(() => _count--),
),
SizedBox(width: 32),
IconButton(
icon: Icon(Icons.add, size: 32),
onPressed: () => setState(() => _count++),
),
],
),
],
);
}
}
4. Explicit Animations (Animasi Eksplisit)
Explicit animations memberikan kontrol penuh atas animasi menggunakan AnimationController. Anda bisa mengontrol durasi, kecepatan, arah (forward/reverse), dan bahkan menghentikan animasi di tengah jalan. Cocok untuk animasi yang memerlukan interaksi pengguna atau timing yang presisi.
import 'package:flutter/material.dart';
class ExplicitAnimationDemo extends StatefulWidget {
@override
_ExplicitAnimationDemoState createState() => _ExplicitAnimationDemoState();
}
// Perlu SingleTickerProviderStateMixin untuk AnimationController
class _ExplicitAnimationDemoState extends State<ExplicitAnimationDemo>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _scaleAnimation;
late Animation<Color?> _colorAnimation;
late Animation<double> _rotationAnimation;
@override
void initState() {
super.initState();
// AnimationController β engine dari animasi
_controller = AnimationController(
duration: Duration(seconds: 2),
vsync: this, // Menggunakan TickerProvider dari mixin
);
// Tween β menentukan range nilai
_scaleAnimation = Tween<double>(begin: 0.5, end: 1.5)
.animate(CurvedAnimation(
parent: _controller,
curve: Curves.elasticOut,
));
_colorAnimation = ColorTween(begin: Colors.blue, end: Colors.purple)
.animate(CurvedAnimation(
parent: _controller,
curve: Interval(0.0, 0.5, curve: Curves.easeIn),
));
_rotationAnimation = Tween<double>(begin: 0, end: 2 * 3.14159)
.animate(CurvedAnimation(
parent: _controller,
curve: Curves.easeInOut,
));
}
@override
void dispose() {
_controller.dispose(); // SELALU dispose controller!
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Explicit Animation')),
body: Center(
child: AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Transform.rotate(
angle: _rotationAnimation.value,
child: Transform.scale(
scale: _scaleAnimation.value,
child: Container(
width: 150,
height: 150,
decoration: BoxDecoration(
color: _colorAnimation.value,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: (_colorAnimation.value ?? Colors.blue)
.withOpacity(0.5),
blurRadius: 20 * _scaleAnimation.value,
),
],
),
child: Center(
child: Icon(Icons.flutter_dash, color: Colors.white, size: 60),
),
),
),
);
},
),
),
floatingActionButton: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
FloatingActionButton(
heroTag: 'forward',
onPressed: () => _controller.forward(),
child: Icon(Icons.play_arrow),
),
SizedBox(width: 12),
FloatingActionButton(
heroTag: 'reverse',
onPressed: () => _controller.reverse(),
child: Icon(Icons.replay),
),
SizedBox(width: 12),
FloatingActionButton(
heroTag: 'reset',
onPressed: () => _controller.reset(),
child: Icon(Icons.stop),
),
],
),
);
}
}
5. Animation Controller & Tween
AnimationController adalah kelas utama yang menggerakkan semua explicit animations. Controller menghasilkan nilai linear dari lowerBound (default 0.0) ke upperBound (default 1.0) dalam durasi yang ditentukan.
// AnimationController β Semua method yang tersedia
_controller.forward() // Mulai dari 0 β 1
_controller.forward(from: 0.5) // Mulai dari 0.5
_controller.reverse() // Mundur dari posisi sekarang β 0
_controller.reverse(from: 0.8) // Mundur dari 0.8
_controller.reset() // Reset ke 0
_controller.stop() // Hentikan di posisi sekarang
_controller.repeat() // Ulangi terus (0β1β0β1...)
_controller.repeat(reverse: true) // Ulangi dengan reverse (0β1β0β1...)
_controller.animateTo(0.7) // Animasi ke 0.7
_controller.animateBack(0.3) // Animasi mundur ke 0.3
_controller.value // Nilai saat ini (0.0-1.0)
_controller.isAnimating // Sedang animasi?
_controller.isCompleted // Sudah selesai?
_controller.isDismissed // Sudah di 0?
// Mendengarkan perubahan
_controller.addListener(() {
print('Value: ${_controller.value}');
});
_controller.addStatusListener((status) {
switch (status) {
case AnimationStatus.forward:
print('Animasi berjalan maju');
break;
case AnimationStatus.reverse:
print('Animasi berjalan mundur');
break;
case AnimationStatus.completed:
print('Animasi selesai');
// Bisa reverse otomatis untuk loop:
// _controller.reverse();
break;
case AnimationStatus.dismissed:
print('Animasi di posisi awal');
break;
}
});
// ===== BERBAGAI JENIS TWEEN =====
// Tween<double> β untuk angka
Tween<double>(begin: 0, end: 100)
// ColorTween β untuk warna
ColorTween(begin: Colors.red, end: Colors.blue)
// IntTween β untuk integer
IntTween(begin: 0, end: 255)
// AlignmentTween β untuk alignment
AlignmentTween(begin: Alignment.topLeft, end: Alignment.bottomRight)
// EdgeInsetsTween β untuk padding/margin
EdgeInsetsTween(
begin: EdgeInsets.all(8),
end: EdgeInsets.all(32),
)
// BorderRadiusTween β untuk border radius
BorderRadiusTween(
begin: BorderRadius.circular(0),
end: BorderRadius.circular(50),
)
// SizeTween β untuk ukuran
SizeTween(
begin: Size(100, 100),
end: Size(200, 200),
)
// RectTween β untuk rect
RectTween(
begin: Rect.fromLTWH(0, 0, 100, 100),
end: Rect.fromLTWH(50, 50, 200, 200),
)
// DecorationTween β untuk dekorasi
DecorationTween(
begin: BoxDecoration(color: Colors.blue, borderRadius: BorderRadius.circular(0)),
end: BoxDecoration(color: Colors.red, borderRadius: BorderRadius.circular(50)),
)
// Matrix4Tween β untuk transform
Matrix4Tween(
begin: Matrix4.identity(),
end: Matrix4.identity()..rotateZ(3.14159),
)
// Chained Tween (berantai)
TweenSequence<double>([
TweenSequenceItem(tween: Tween(begin: 0, end: 100), weight: 40),
TweenSequenceItem(tween: Tween(begin: 100, end: 50), weight: 30),
TweenSequenceItem(tween: Tween(begin: 50, end: 200), weight: 30),
])
AnimatedBuilder digunakan ketika Anda ingin membangun widget tree baru setiap frame. AnimatedWidget digunakan ketika Anda ingin membuat class widget yang bisa di-animasi β lebih reusable. Untuk kebanyakan kasus, AnimatedBuilder sudah cukup dan lebih fleksibel.
6. Curves & Easing
Curves menentukan bagaimana nilai animasi berubah seiring waktu (easing function). Tanpa curve, animasi berjalan linear (konstan). Dengan curve, animasi bisa dipercepat, diperlambat, bahkan memantul.
| Curve | Karakteristik | Cocok Untuk |
|---|---|---|
| Curves.linear | Konstan, tanpa easing | Progress bar |
| Curves.easeIn | Lambat di awal, cepat di akhir | Objek jatuh |
| Curves.easeOut | Cepat di awal, lambat di akhir | Menu muncul |
| Curves.easeInOut | Lambat-cepat-lambat | Transisi halaman |
| Curves.bounceOut | Memantul di akhir | Objek jatuh & memantul |
| Curves.bounceIn | Memantul di awal | Efek khusus |
| Curves.elasticOut | Elastis/memantul | Pop-up, tooltip |
| Curves.elasticInOut | Elastis di awal & akhir | Interaksi playful |
| Curves.decelerate | Melambat tajam | Scroll physics |
| Curves.fastOutSlowIn | Material Design standard | Animasi Material |
// Custom curve β Cubic Bezier
const customCurve = Cubic(0.68, -0.55, 0.265, 1.55); // Overshoot
// Interval β Membagi durasi untuk sequenced animation
// Interval(begin, end, curve) β animasi hanya berjalan di range begin-end
final opacityAnimation = Tween<double>(begin: 0, end: 1).animate(
CurvedAnimation(
parent: _controller,
curve: Interval(0.0, 0.5, curve: Curves.easeIn),
// Fade in selama 50% pertama durasi
),
);
final slideAnimation = Tween<Offset>(
begin: Offset(0, 0.5),
end: Offset.zero,
).animate(
CurvedAnimation(
parent: _controller,
curve: Interval(0.3, 0.8, curve: Curves.easeOut),
// Slide masuk selama 30%-80% durasi
),
);
final scaleAnimation = Tween<double>(begin: 0.8, end: 1.0).animate(
CurvedAnimation(
parent: _controller,
curve: Interval(0.5, 1.0, curve: Curves.elasticOut),
// Scale di 50%-100% durasi
),
);
// Menggunakan di AnimatedBuilder
AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return FadeTransition(
opacity: opacityAnimation,
child: SlideTransition(
position: slideAnimation,
child: ScaleTransition(
scale: scaleAnimation,
child: child,
),
),
);
},
child: MyCard(), // Child tidak rebuild β optimisasi!
)
7. Hero Animations
Hero animation (shared element transition) adalah animasi di mana widget berpindah dari satu halaman ke halaman lain dengan animasi yang mulus. Ini adalah pola UI yang sangat umum di mobile apps β ketika user menekan item di list, item tersebut "terbang" ke halaman detail.
import 'package:flutter/material.dart';
// ===== HERO ANIMATION DASAR =====
// Halaman 1: List Produk
class ProductListPage extends StatelessWidget {
final List<Map<String, dynamic>> products = [
{'id': '1', 'name': 'iPhone 16 Pro', 'price': 'Rp 18.999.000', 'icon': Icons.phone_iphone, 'color': Colors.blue},
{'id': '2', 'name': 'MacBook Air M3', 'price': 'Rp 16.999.000', 'icon': Icons.laptop_mac, 'color': Colors.grey},
{'id': '3', 'name': 'iPad Pro M4', 'price': 'Rp 14.999.000', 'icon': Icons.tablet_mac, 'color': Colors.purple},
{'id': '4', 'name': 'AirPods Pro 3', 'price': 'Rp 3.999.000', 'icon': Icons.headphones, 'color': Colors.green},
{'id': '5', 'name': 'Apple Watch Ultra', 'price': 'Rp 12.999.000', 'icon': Icons.watch, 'color': Colors.orange},
];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Produk')),
body: ListView.builder(
padding: EdgeInsets.all(16),
itemCount: products.length,
itemBuilder: (context, index) {
final product = products[index];
return GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ProductDetailPage(product: product),
),
);
},
child: Card(
margin: EdgeInsets.only(bottom: 12),
child: Padding(
padding: EdgeInsets.all(16),
child: Row(
children: [
// HERO WIDGET β bagian yang di-animasi
Hero(
tag: 'product-${product['id']}', // Tag harus unik & sama di kedua halaman
child: Container(
width: 60,
height: 60,
decoration: BoxDecoration(
color: product['color'].withOpacity(0.2),
borderRadius: BorderRadius.circular(12),
),
child: Icon(product['icon'], color: product['color'], size: 32),
),
),
SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(product['name'], style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
SizedBox(height: 4),
Text(product['price'], style: TextStyle(color: Colors.blue, fontWeight: FontWeight.bold)),
],
),
),
Icon(Icons.arrow_forward_ios, color: Colors.grey),
],
),
),
),
);
},
),
);
}
}
// Halaman 2: Detail Produk
class ProductDetailPage extends StatelessWidget {
final Map<String, dynamic> product;
ProductDetailPage({required this.product});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(product['name'])),
body: Padding(
padding: EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
// HERO WIDGET β tag sama dengan di list
Hero(
tag: 'product-${product['id']}',
child: Container(
width: 200, // Lebih besar di halaman detail
height: 200,
decoration: BoxDecoration(
color: product['color'].withOpacity(0.2),
borderRadius: BorderRadius.circular(24),
),
child: Icon(product['icon'], color: product['color'], size: 80),
),
),
),
SizedBox(height: 32),
Text(product['name'], style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold)),
SizedBox(height: 8),
Text(product['price'], style: TextStyle(fontSize: 24, color: Colors.blue, fontWeight: FontWeight.w600)),
SizedBox(height: 24),
Text('Deskripsi produk akan ditampilkan di sini...', style: TextStyle(fontSize: 16, color: Colors.grey[600])),
Spacer(),
SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton(
onPressed: () {},
style: ElevatedButton.styleFrom(
backgroundColor: product['color'],
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
),
child: Text('Beli Sekarang', style: TextStyle(fontSize: 18, color: Colors.white)),
),
),
],
),
),
);
}
}
// ===== HERO DENGAN CUSTOM FLIGHT SHUTTLE =====
// Untuk mengontrol bagaimana Hero berpindah
Hero(
tag: 'custom-hero',
flightShuttleBuilder: (flightContext, animation, direction, fromContext, toContext) {
return ScaleTransition(
scale: animation.drive(Tween(begin: 0.5, end: 1.0)),
child: RotationTransition(
turns: animation,
child: toContext.widget,
),
);
},
child: MyWidget(),
)
8. Custom Page Transitions
import 'package:flutter/material.dart';
// ===== CUSTOM PAGE TRANSITIONS =====
// Slide dari kanan
class SlideRightRoute extends PageRouteBuilder {
final Widget page;
SlideRightRoute({required this.page})
: super(
pageBuilder: (context, animation, secondaryAnimation) => page,
transitionsBuilder: (context, animation, secondaryAnimation, child) {
var tween = Tween(begin: Offset(1.0, 0.0), end: Offset.zero)
.chain(CurveTween(curve: Curves.easeInOut));
return SlideTransition(
position: animation.drive(tween),
child: child,
);
},
transitionDuration: Duration(milliseconds: 400),
);
}
// Fade transition
class FadeRoute extends PageRouteBuilder {
final Widget page;
FadeRoute({required this.page})
: super(
pageBuilder: (context, animation, secondaryAnimation) => page,
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return FadeTransition(
opacity: animation,
child: child,
);
},
);
}
// Scale + Fade transition (zoom in)
class ScaleFadeRoute extends PageRouteBuilder {
final Widget page;
ScaleFadeRoute({required this.page})
: super(
pageBuilder: (context, animation, secondaryAnimation) => page,
transitionsBuilder: (context, animation, secondaryAnimation, child) {
var fadeAnimation = Tween(begin: 0.0, end: 1.0).animate(
CurvedAnimation(parent: animation, curve: Interval(0.0, 0.5)),
);
var scaleAnimation = Tween(begin: 0.8, end: 1.0).animate(
CurvedAnimation(parent: animation, curve: Curves.easeOutBack),
);
return FadeTransition(
opacity: fadeAnimation,
child: ScaleTransition(
scale: scaleAnimation,
child: child,
),
);
},
);
}
// Rotation + Scale transition
class RotationRoute extends PageRouteBuilder {
final Widget page;
RotationRoute({required this.page})
: super(
pageBuilder: (context, animation, secondaryAnimation) => page,
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return RotationTransition(
turns: Tween(begin: 0.5, end: 1.0).animate(
CurvedAnimation(parent: animation, curve: Curves.easeOut),
),
child: ScaleTransition(
scale: Tween(begin: 0.0, end: 1.0).animate(
CurvedAnimation(parent: animation, curve: Curves.easeOut),
),
child: child,
),
);
},
);
}
// Menggunakan custom transitions
Navigator.push(context, SlideRightRoute(page: DetailPage()));
Navigator.push(context, FadeRoute(page: ProfilePage()));
Navigator.push(context, ScaleFadeRoute(page: SettingsPage()));
9. Staggered Animations
Staggered animation adalah serangkaian animasi yang berjalan secara berurutan dengan timing yang berbeda-beda β misalnya elemen pertama muncul, lalu elemen kedua, lalu ketiga. Ini menciptakan efek yang lebih natural dan menarik dibanding semua elemen animasi bersamaan.
import 'package:flutter/material.dart';
class StaggeredAnimationDemo extends StatefulWidget {
@override
_StaggeredAnimationDemoState createState() => _StaggeredAnimationDemoState();
}
class _StaggeredAnimationDemoState extends State<StaggeredAnimationDemo>
with TickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _headerAnimation;
late Animation<double> _card1Animation;
late Animation<double> _card2Animation;
late Animation<double> _card3Animation;
late Animation<double> _buttonAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: Duration(milliseconds: 2000),
vsync: this,
);
// Header: 0% - 30% durasi
_headerAnimation = Tween<double>(begin: 0, end: 1).animate(
CurvedAnimation(
parent: _controller,
curve: Interval(0.0, 0.3, curve: Curves.easeOut),
),
);
// Card 1: 15% - 45% durasi
_card1Animation = Tween<double>(begin: 0, end: 1).animate(
CurvedAnimation(
parent: _controller,
curve: Interval(0.15, 0.45, curve: Curves.easeOut),
),
);
// Card 2: 30% - 60% durasi
_card2Animation = Tween<double>(begin: 0, end: 1).animate(
CurvedAnimation(
parent: _controller,
curve: Interval(0.30, 0.60, curve: Curves.easeOut),
),
);
// Card 3: 45% - 75% durasi
_card3Animation = Tween<double>(begin: 0, end: 1).animate(
CurvedAnimation(
parent: _controller,
curve: Interval(0.45, 0.75, curve: Curves.easeOut),
),
);
// Button: 60% - 100% durasi
_buttonAnimation = Tween<double>(begin: 0, end: 1).animate(
CurvedAnimation(
parent: _controller,
curve: Interval(0.60, 1.0, curve: Curves.elasticOut),
),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Widget _buildAnimatedCard({
required Animation<double> animation,
required String title,
required String subtitle,
required IconData icon,
required Color color,
}) {
return AnimatedBuilder(
animation: animation,
builder: (context, child) {
return Transform.translate(
offset: Offset(0, 50 * (1 - animation.value)),
child: Opacity(
opacity: animation.value,
child: child,
),
);
},
child: Card(
margin: EdgeInsets.only(bottom: 12),
child: ListTile(
leading: CircleAvatar(
backgroundColor: color.withOpacity(0.2),
child: Icon(icon, color: color),
),
title: Text(title),
subtitle: Text(subtitle),
),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Staggered Animation')),
body: Padding(
padding: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header β muncul duluan
AnimatedBuilder(
animation: _headerAnimation,
builder: (context, child) {
return Transform.translate(
offset: Offset(-100 * (1 - _headerAnimation.value), 0),
child: Opacity(
opacity: _headerAnimation.value,
child: child,
),
);
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Dashboard', style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold)),
Text('Selamat datang kembali, Budi!', style: TextStyle(color: Colors.grey)),
],
),
),
SizedBox(height: 24),
// Cards β muncul berurutan
_buildAnimatedCard(
animation: _card1Animation,
title: 'Total Pesanan',
subtitle: '156 pesanan',
icon: Icons.shopping_bag,
color: Colors.blue,
),
_buildAnimatedCard(
animation: _card2Animation,
title: 'Pendapatan',
subtitle: 'Rp 12.500.000',
icon: Icons.attach_money,
color: Colors.green,
),
_buildAnimatedCard(
animation: _card3Animation,
title: 'Pelanggan Baru',
subtitle: '48 pelanggan',
icon: Icons.people,
color: Colors.purple,
),
SizedBox(height: 24),
// Button β muncul terakhir dengan efek elastic
AnimatedBuilder(
animation: _buttonAnimation,
builder: (context, child) {
return Transform.scale(
scale: _buttonAnimation.value,
child: child,
);
},
child: SizedBox(
width: double.infinity,
height: 50,
child: ElevatedButton(
onPressed: () {},
child: Text('Lihat Detail'),
),
),
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
if (_controller.isCompleted) {
_controller.reverse();
} else {
_controller.forward();
}
},
child: Icon(Icons.play_arrow),
),
);
}
}
10. Best Practices & Performa
| Best Practice | Penjelasan |
|---|---|
| Selalu dispose() | AnimationController harus di-dispose di method dispose() untuk menghindari memory leak |
| Gunakan AnimatedBuilder | Pisahkan widget yang animasi dari yang statis β gunakan child parameter |
| Pilih tepat | Implicit untuk animasi sederhana, explicit untuk kontrol penuh |
| Durasi yang tepat | 200-500ms untuk micro-interactions, 500-1000ms untuk transisi halaman |
| Curves | Hindari linear untuk kebanyakan animasi β gunakan easeInOut atau curves lainnya |
| 60fps | Pastikan animasi berjalan 60fps β hindari operasi berat di builder |
| Reduce rebuild | Gunakan child parameter di AnimatedBuilder untuk widget yang tidak berubah |
| Accessibility | Berikan opsi untuk menonaktifkan animasi bagi user yang sensitif |
// β BURUK: Semua widget rebuild setiap frame
AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Container(
decoration: BoxDecoration(color: Colors.blue),
child: Column(
children: [
Text('Title'), // Ini ikut rebuild padahal statis!
ExpensiveWidget(), // Ini juga!
Transform.scale(
scale: _animation.value,
child: Icon(Icons.star),
),
],
),
);
},
)
// β
BAIK: Hanya bagian yang berubah yang rebuild
Container(
decoration: BoxDecoration(color: Colors.blue),
child: Column(
children: [
Text('Title'), // Tidak rebuild β di luar AnimatedBuilder
ExpensiveWidget(), // Tidak rebuild
AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Transform.scale(
scale: _animation.value,
child: child, // Menggunakan child β tidak rebuild!
);
},
child: Icon(Icons.star), // Child statis β dibuat sekali saja
),
],
),
)
// β
BAHKAN LEBIH BAIK: Gunakan built-in transition widget
FadeTransition(
opacity: _fadeAnimation,
child: ScaleTransition(
scale: _scaleAnimation,
child: Icon(Icons.star), // Dibuat sekali, di-reuse setiap frame
),
)
Beberapa pengguna memiliki sensitivitas terhadap gerakan (vestibular disorders). Flutter mendeteksi MediaQuery.disableAnimations β periksa ini dan kurangi atau hilangkan animasi untuk pengguna tersebut. Gunakan MediaQuery.of(context).disableAnimations untuk memeriksa preferensi ini.
11. Quiz Pemahaman
1. Apa perbedaan implicit dan explicit animation?
2. Mengapa AnimationController harus di-dispose?
3. Apa fungsi Hero widget di Flutter?
4. Apa perbedaan Curves.easeIn dan Curves.easeOut?
5. Apa itu staggered animation?
Anda telah mempelajari sistem animasi Flutter secara mendalam β dari implicit animations yang sederhana, explicit animations yang powerful, Hero transitions, custom page transitions, hingga staggered animations. Animasi yang baik membuat aplikasi terasa hidup dan profesional!