Flutter #27: Hero Animation & Page Transition — Transisi Antar Halaman yang Smooth
Halo! Di artikel #27 ini kita akan belajar tentang Hero Animation dan Page Transition di Flutter. Dua fitur ini bikin user experience app kamu naik level — dari “bisa dipakai” jadi “enak dipandang dan dipakai.”
Kalau selama ini halaman kamu cuma ngeslide dari samping, hari ini kita akan upgrade transisinya jadi jauh lebih smooth dan profesional. Let’s go!
1. Hero Animation — Terbang dari Halaman ke Halaman
Hero Animation adalah animasi yang bikin sebuah widget “terbang” dari posisi di halaman A ke posisi di halaman B. Contoh paling umum: gambar thumbnail di list → membesar jadi gambar full di detail page.
Cara Kerja Hero
- Di halaman asal, bungkus widget dengan
Hero(tag: 'unik-id') - Di halaman tujuan, bungkus widget serupa dengan
Hero(tag: 'sama') - Flutter otomatis animasikan transisi posisi + ukuran
Contoh Dasar Hero Animation
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Hero Demo',
theme: ThemeData(colorSchemeSeed: Colors.teal, useMaterial3: true),
home: const GalleryPage(),
);
}
}
// Halaman Galeri — daftar gambar
class GalleryPage extends StatelessWidget {
const GalleryPage({super.key});
// Data dummy gambar
final List<Map<String, String>> photos = const [
{'tag': 'photo1', 'color': '#FF6B6B', 'title': 'Sunset'},
{'tag': 'photo2', 'color': '#4ECDC4', 'title': 'Ocean'},
{'tag': 'photo3', 'color': '#45B7D1', 'title': 'Sky'},
{'tag': 'photo4', 'color': '#96CEB4', 'title': 'Forest'},
];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Galeri Foto')),
body: GridView.builder(
padding: const EdgeInsets.all(12),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
itemCount: photos.length,
itemBuilder: (context, index) {
final photo = photos[index];
return GestureDetector(
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => DetailPage(
tag: photo['tag']!,
color: photo['color']!,
title: photo['title']!,
),
),
),
child: Hero(
tag: photo['tag']!,
child: Container(
decoration: BoxDecoration(
color: Color(int.parse(photo['color']!.replaceFirst('#', '0xFF'))),
borderRadius: BorderRadius.circular(16),
),
alignment: Alignment.center,
child: Text(
photo['title']!,
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
),
),
);
},
),
);
}
}
// Halaman Detail — tampilan penuh
class DetailPage extends StatelessWidget {
final String tag;
final String color;
final String title;
const DetailPage({
super.key,
required this.tag,
required this.color,
required this.title,
});
@override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 300,
pinned: true,
flexibleSpace: FlexibleSpaceBar(
title: Text(title),
background: Hero(
tag: tag,
child: Container(
color: Color(int.parse(color.replaceFirst('#', '0xFF'))),
alignment: Alignment.center,
child: Icon(
Icons.photo,
size: 80,
color: Colors.white.withValues(alpha: 0.5),
),
),
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Tentang $title',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 12),
const Text(
'Ini adalah halaman detail foto. Hero animation membuat gambar '
'terbang dari thumbnail di galeri ke posisi full di sini. '
'Animasi otomatis oleh Flutter — cukup pakai Hero(tag: ...) '
'di kedua halaman!',
style: TextStyle(fontSize: 16, height: 1.5),
),
],
),
),
),
],
),
);
}
}
Yang perlu diperhatikan:
tagharus unik per hero — Setiap Hero punya tag yang sama di halaman A dan B. Jangan pakai tag yang sama untuk dua hero berbeda!- Widget tidak harus identik — Yang penting tag-nya sama. Bisa Thumbnail di halaman A → gambar besar di halaman B.
- Tanpa Navigator.push perlu — Hero animasi otomatis jalan setiap kali kamu navigasi ke halaman yang punya widget Hero dengan tag yang sama.
2. Custom Page Transition — Upgrade dari Default Slide
Secara default, MaterialPageRoute bikin halaman geser dari kanan. Tapi kamu bisa bikin transisi yang lebih keren: fade, scale, rotate, atau kombinasi.
Contoh: Custom Fade + Slide Transition
// Custom page route dengan fade + slide
class FadeSlidePageRoute<T> extends PageRouteBuilder<T> {
final Widget page;
FadeSlidePageRoute({required this.page})
: super(
transitionDuration: const Duration(milliseconds: 400),
pageBuilder: (context, animation, secondaryAnimation) => page,
transitionsBuilder: (context, animation, secondaryAnimation, child) {
// Tween untuk slide dari bawah
final slideTween = Tween<Offset>(
begin: const Offset(0, 0.1),
end: Offset.zero,
).chain(CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
));
// Tween untuk fade
final fadeTween = Tween<double>(begin: 0.0, end: 1.0);
return SlideTransition(
position: slideTween,
child: FadeTransition(
opacity: fadeTween.animate(animation),
child: child,
),
);
},
);
}
// Cara pakai:
// Navigator.push(context, FadeSlidePageRoute(page: TargetPage()));
Contoh: Scale Transition (Zoom In)
class ScalePageRoute<T> extends PageRouteBuilder<T> {
final Widget page;
ScalePageRoute({required this.page})
: super(
transitionDuration: const Duration(milliseconds: 350),
pageBuilder: (context, animation, secondaryAnimation) => page,
transitionsBuilder: (context, animation, secondaryAnimation, child) {
final scaleTween = Tween<double>(begin: 0.8, end: 1.0)
.chain(CurveTween(curve: Curves.easeOutBack));
final fadeTween = Tween<double>(begin: 0.0, end: 1.0);
return ScaleTransition(
scale: scaleTween.animate(animation),
child: FadeTransition(
opacity: fadeTween.animate(animation),
child: child,
),
);
},
);
}
// Pakai untuk detail card:
// Navigator.push(context, ScalePageRoute(page: DetailPage()));
3. Contoh Lengkap: App dengan Hero + Custom Transition
Berikut app sederhana yang menggabungkan Hero Animation dengan Custom Page Transition:
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Fancy Transitions',
theme: ThemeData(colorSchemeSeed: Colors.deepPurple, useMaterial3: true),
home: const HomePage(),
);
}
}
class HomePage extends StatelessWidget {
const HomePage({super.key});
final List<Map<String, dynamic>> items = const [
{'id': 1, 'icon': Icons.pets, 'label': 'Animals', 'color': 0xFFFF6B6B},
{'id': 2, 'icon': Icons.restaurant, 'label': 'Food', 'color': 0xFF4ECDC4},
{'id': 3, 'icon': Icons.flight, 'label': 'Travel', 'color': 0xFF45B7D1},
{'id': 4, 'icon': Icons.music_note, 'label': 'Music', 'color': 0xFFF7DC6F},
{'id': 5, 'icon': Icons.brush, 'label': 'Art', 'color': 0xFFBB8FCE},
{'id': 6, 'icon': Icons.code, 'label': 'Code', 'color': 0xFF82E0AA},
];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Fancy Transitions')),
body: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: ListTile(
onTap: () {
Navigator.push(
context,
FadeSlidePageRoute(
page: ItemDetailPage(item: item),
),
);
},
leading: Hero(
tag: 'item-${item['id']}',
child: CircleAvatar(
backgroundColor: Color(item['color']),
radius: 28,
child: Icon(item['icon'] as IconData, color: Colors.white),
),
),
title: Text(item['label'] as String, style: const TextStyle(fontWeight: FontWeight.w600)),
subtitle: const Text('Tap untuk lihat detail'),
trailing: const Icon(Icons.chevron_right),
),
);
},
),
);
}
}
class ItemDetailPage extends StatelessWidget {
final Map<String, dynamic> item;
const ItemDetailPage({super.key, required this.item});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(item['label'] as String)),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Hero(
tag: 'item-${item['id']}',
child: CircleAvatar(
backgroundColor: Color(item['color']),
radius: 60,
child: Icon(item['icon'] as IconData, color: Colors.white, size: 48),
),
),
const SizedBox(height: 24),
Text(
item['label'] as String,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
const Text(
'Hero animation membuat icon terbang dari list ke sini.\n'
'Halaman ini dibuka dengan FadeSlide transition.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 16, color: Colors.grey),
),
],
),
),
);
}
}
// FadeSlidePageRoute (definisi lengkap di atas)
class FadeSlidePageRoute<T> extends PageRouteBuilder<T> {
final Widget page;
FadeSlidePageRoute({required this.page})
: super(
transitionDuration: const Duration(milliseconds: 400),
pageBuilder: (context, animation, secondaryAnimation) => page,
transitionsBuilder: (context, animation, secondaryAnimation, child) {
final slideTween = Tween<Offset>(
begin: const Offset(0, 0.1),
end: Offset.zero,
).chain(CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
));
final fadeTween = Tween<double>(begin: 0.0, end: 1.0);
return SlideTransition(
position: slideTween,
child: FadeTransition(
opacity: fadeTween.animate(animation),
child: child,
),
);
},
);
}
4. Page Transition Bawaan Flutter
Selain custom route, Flutter juga punya beberapa transisi bawaan yang bisa kamu pakai tanpa bikin custom PageRouteBuilder:
// Fade transition
Navigator.push(context, PageRouteBuilder(
pageBuilder: (_, __, ___) => const TargetPage(),
transitionsBuilder: (_, animation, __, child) =>
FadeTransition(opacity: animation, child: child),
));
// Scale transition
Navigator.push(context, PageRouteBuilder(
pageBuilder: (_, __, ___) => const TargetPage(),
transitionsBuilder: (_, animation, __, child) =>
ScaleTransition(scale: animation, child: child),
));
// Rotation transition
Navigator.push(context, PageRouteBuilder(
pageBuilder: (_, __, ___) => const TargetPage(),
transitionsBuilder: (_, animation, __, child) =>
RotationTransition(turns: animation, child: child),
));
Tip: Untuk transisi sederhana, cara inline di atas sudah cukup. Kalau transisi-nya dipakai di banyak tempat, buat class sendiri seperti FadeSlidePageRoute agar tidak copy-paste.
Recap Hari Ini
| Teknik | Fungsi | Kapan Pakai |
|---|---|---|
| Hero Animation | Widget terbang antar halaman | Thumbnail → Detail, avatar → Profile |
| PageRouteBuilder | Transisi custom per navigasi | Fade, scale, rotate, atau kombinasi |
| SlideTransition | Geser dari posisi tertentu | Bottom-to-top, left-to-right |
| FadeTransition | Opacity dari 0 → 1 | Transisi subtle dan elegan |
| ScaleTransition | Zoom in/out | Popup feel, card expand |
Yang harus diingat:
- Hero
tagharus unik dan sama di kedua halaman - Widget Hero tidak harus identis — yang penting tag-nya match
- Custom
PageRouteBuilderberitransitionDurationagar timing pas - Kombinasikan
SlideTransition+FadeTransitionuntuk hasil yang lebih polished Curves.easeOutCubicdanCurves.easeOutBackbikin animasi terasa natural
Di artikel #28 kita akan belajar tentang Custom Widget & Reusable Component — bikin widget sendiri yang bisa dipakai ulang di seluruh app! 🧩
Coba sendiri! Bikin galeri foto sederhana dengan Hero Animation. Share hasilnya ke sosial media dan tag @ahsai001! 🚀