Scrolling #
Scrolling adalah salah satu interaksi paling mendasar di aplikasi mobile. Flutter menyediakan ekosistem scrolling yang sangat kaya — dari ListView sederhana hingga CustomScrollView dengan Sliver yang memungkinkan efek scrolling kompleks seperti collapsing app bar, parallax, dan grid-list hybrid. Semua scrolling di Flutter dibangun di atas sistem Sliver yang sama.
Arsitektur Scrolling Flutter #
Sebelum masuk ke widget-widget spesifik, penting memahami bagaimana scrolling bekerja di balik layar:
ScrollView (misal: ListView)
└── Scrollable (menangani gesture: drag, fling, scroll physics)
└── Viewport (jendela ke konten yang di-scroll)
└── Sliver(s) (konten yang sebenarnya)
└── Children (widget yang ditampilkan)
Hanya item yang terlihat di Viewport yang di-build!
Item di luar viewport tidak di-build -- lazy loading otomatis
Ketika kamu memiliki list item yang panjang di ListView atau GridView, kamu bisa membangun item on demand saat mereka masuk ke viewport saat di-scroll. Ini memberikan pengalaman scrolling yang jauh lebih performan.
ListView — List Vertikal/Horizontal #
ListView adalah widget scrolling yang paling sering digunakan. Ia menampilkan children satu per satu dalam arah scroll.
ListView (Constructor Langsung) #
// Untuk list PENDEK dan TETAP -- semua item dibuat sekaligus
ListView(
padding: const EdgeInsets.all(8),
children: [
ListTile(title: const Text('Item 1')),
ListTile(title: const Text('Item 2')),
ListTile(title: const Text('Item 3')),
const Divider(),
ListTile(title: const Text('Item 4')),
],
)
ListView.builder — Lazy Loading #
// Untuk list PANJANG atau DINAMIS -- item dibuat saat masuk viewport
ListView.builder(
itemCount: produk.length, // null = infinite list
itemExtent: 80, // opsional: tinggi tetap untuk performa optimal
itemBuilder: (context, index) {
return ListTile(
key: ValueKey(produk[index].id),
leading: CircleAvatar(child: Text('${index + 1}')),
title: Text(produk[index].nama),
subtitle: Text('Rp ${produk[index].harga}'),
trailing: const Icon(Icons.chevron_right),
onTap: () => navigateToDetail(produk[index]),
);
},
)
ListView.separated — List dengan Pemisah #
// Tambah separator antar item secara otomatis
ListView.separated(
itemCount: items.length,
separatorBuilder: (context, index) => const Divider(height: 1),
itemBuilder: (context, index) {
return ListTile(title: Text(items[index]));
},
)
ListView.custom — Kontrol Penuh #
// Gunakan SliverChildDelegate kustom
ListView.custom(
childrenDelegate: SliverChildBuilderDelegate(
(context, index) => ListTile(title: Text('Item $index')),
childCount: 100,
// Callback saat item keluar viewport -- bisa digunakan untuk cleanup
addAutomaticKeepAlives: false,
),
)
ListView Horizontal #
ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: kategori.length,
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.all(8),
child: Chip(label: Text(kategori[index])),
);
},
)
GridView — Layout Grid 2D #
GridView menampilkan children dalam susunan dua dimensi — kolom dan baris.
GridView.count — Jumlah Kolom Tetap #
GridView.count(
crossAxisCount: 2, // 2 kolom
mainAxisSpacing: 8, // jarak vertikal antar item
crossAxisSpacing: 8, // jarak horizontal antar item
childAspectRatio: 3 / 4, // rasio lebar:tinggi setiap item
padding: const EdgeInsets.all(8),
children: produk.map((p) => ProdukCard(produk: p)).toList(),
)
GridView.builder — Lazy Loading untuk Grid #
GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
mainAxisSpacing: 4,
crossAxisSpacing: 4,
),
itemCount: foto.length,
itemBuilder: (context, index) {
return Image.network(
foto[index].url,
fit: BoxFit.cover,
);
},
)
GridView dengan Ukuran Tile Maksimum #
// SliverGridDelegateWithMaxCrossAxisExtent:
// Jumlah kolom dihitung otomatis dari lebar layar
GridView.builder(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 200, // maksimum 200px per tile
mainAxisSpacing: 8,
crossAxisSpacing: 8,
childAspectRatio: 1, // tile berbentuk persegi
),
itemCount: items.length,
itemBuilder: (context, index) => ItemCard(item: items[index]),
)
SingleChildScrollView — Konten yang Bisa Di-scroll #
Digunakan ketika konten yang mungkin melebihi layar adalah satu widget tunggal (seperti Column):
SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Judul', style: TextStyle(fontSize: 24)),
const SizedBox(height: 16),
const Text('Paragraf panjang yang mungkin melebihi tinggi layar...'),
// ... banyak konten
ElevatedButton(
onPressed: _submit,
child: const Text('Submit'),
),
],
),
)
HindariSingleChildScrollViewdenganColumnberisi list besar.Columnmembuat semua children sekaligus — tidak ada lazy loading. Untuk list besar, selalu gunakanListView.builder.
PageView — Scrolling Per Halaman #
PageView adalah widget scrolling yang bergerak per halaman penuh — sempurna untuk onboarding, carousel, atau tab konten:
class OnboardingScreen extends StatefulWidget {
const OnboardingScreen({super.key});
@override
State<OnboardingScreen> createState() => _OnboardingScreenState();
}
class _OnboardingScreenState extends State<OnboardingScreen> {
final _controller = PageController();
int _currentPage = 0;
@override
Widget build(BuildContext context) {
return Column(
children: [
Expanded(
child: PageView(
controller: _controller,
onPageChanged: (page) => setState(() => _currentPage = page),
children: const [
OnboardingPage(judul: 'Selamat Datang', ikon: Icons.waving_hand),
OnboardingPage(judul: 'Fitur Unggulan', ikon: Icons.star),
OnboardingPage(judul: 'Mulai Sekarang', ikon: Icons.rocket_launch),
],
),
),
// Indikator halaman
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(3, (i) => AnimatedContainer(
duration: const Duration(milliseconds: 200),
margin: const EdgeInsets.all(4),
width: i == _currentPage ? 24 : 8,
height: 8,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
color: i == _currentPage ? Colors.blue : Colors.grey,
),
)),
),
],
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
}
ScrollController — Kontrol Programatik #
ScrollController memungkinkan kontrol scroll secara programatik dan mendengarkan perubahan posisi scroll:
class _ScrollableScreenState extends State<ScrollableScreen> {
final _scrollController = ScrollController();
bool _showBackToTop = false;
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
}
void _onScroll() {
final showButton = _scrollController.offset > 300;
if (showButton != _showBackToTop) {
setState(() => _showBackToTop = showButton);
}
}
void _scrollToTop() {
_scrollController.animateTo(
0,
duration: const Duration(milliseconds: 500),
curve: Curves.easeOut,
);
}
void _scrollToBottom() {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 500),
curve: Curves.easeOut,
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: ListView.builder(
controller: _scrollController,
itemCount: 100,
itemBuilder: (context, index) => ListTile(
title: Text('Item $index'),
),
),
floatingActionButton: _showBackToTop
? FloatingActionButton(
onPressed: _scrollToTop,
child: const Icon(Icons.arrow_upward),
)
: null,
);
}
@override
void dispose() {
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
}
Sistem Sliver — Fondasi Semua Scrolling #
Sliver adalah potongan kecil dari area yang bisa di-scroll di dalam CustomScrollView yang bisa dikonfigurasi untuk berperilaku dengan cara tertentu. Menggunakan Sliver Flutter, kita bisa membuat berbagai efek scrolling yang luar biasa. Sliver digunakan oleh semua scrollable view di Flutter — misalnya, ListView menggunakan SliverList dan GridView menggunakan SliverGrid.
CustomScrollView menggunakan beberapa Sliver:
CustomScrollView
├── SliverAppBar (app bar yang bisa collapse/expand)
├── SliverToBoxAdapter (widget biasa di dalam scroll)
├── SliverList (list vertikal)
├── SliverGrid (grid 2D)
└── SliverFillRemaining (isi sisa viewport)
CustomScrollView — Kombinasi Sliver #
CustomScrollView(
slivers: [
// App bar yang bisa collapse saat scroll
SliverAppBar(
pinned: true, // tetap terlihat saat scroll
expandedHeight: 200,
flexibleSpace: FlexibleSpaceBar(
title: const Text('Toko Kami'),
background: Image.asset('header.jpg', fit: BoxFit.cover),
),
),
// Widget biasa (bukan Sliver) dibungkus SliverToBoxAdapter
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
'Produk Unggulan',
style: Theme.of(context).textTheme.headlineSmall,
),
),
),
// Grid produk unggulan
SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
childAspectRatio: 0.75,
),
delegate: SliverChildBuilderDelegate(
(context, index) => ProdukCard(produk: unggulan[index]),
childCount: unggulan.length,
),
),
// Judul seksi berikutnya
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(16),
child: Text('Semua Produk'),
),
),
// List semua produk
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => ProdukListTile(produk: semuaProduk[index]),
childCount: semuaProduk.length,
),
),
],
)
SliverAppBar — App Bar yang Dinamis #
SliverAppBar(
// Tinggi saat expand
expandedHeight: 250,
// Tetap terlihat saat scroll ke bawah (tidak menghilang)
pinned: true,
// Muncul kembali saat scroll ke atas sedikit
floating: false,
// Snap ke posisi expand/collapse penuh
snap: false,
flexibleSpace: FlexibleSpaceBar(
title: const Text('Detail Produk'),
titlePadding: const EdgeInsets.only(left: 16, bottom: 16),
background: Stack(
fit: StackFit.expand,
children: [
Image.network(produk.imageUrl, fit: BoxFit.cover),
// Gradient overlay agar teks terbaca
const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.transparent, Colors.black54],
),
),
),
],
),
),
actions: [
IconButton(icon: const Icon(Icons.share), onPressed: _share),
IconButton(icon: const Icon(Icons.favorite_border), onPressed: _favorite),
],
)
SliverFixedExtentList — Performa Optimal #
// Untuk list dengan tinggi item yang sama -- JAUH lebih performan
// karena Flutter bisa menghitung posisi setiap item secara O(1)
SliverFixedExtentList(
itemExtent: 72, // semua item PERSIS 72px tingginya
delegate: SliverChildBuilderDelegate(
(context, index) => UserListTile(user: users[index]),
childCount: users.length,
),
)
Ini berdampak signifikan pada performa ListView saat scrolling karena mengetahui ukuran item sebelum memuatnya sangat menguntungkan ketika ingin melompat jarak jauh. Misalnya, jika kita tahu setiap item berukuran tetap 50px dan ingin scroll 5.000px ke bawah, kita bisa langsung melompat 100 item dan menampilkan item ke-101.
Anti-Pattern: shrinkWrap dan NeverScrollableScrollPhysics #
Kombinasi ini sangat sering ditemukan di kode Flutter pemula, tapi sangat berbahaya untuk performa:
// ANTI-PATTERN yang sering ditemukan:
Column(
children: [
ListView.builder(
shrinkWrap: true, // BAHAYA!
physics: const NeverScrollableScrollPhysics(), // BAHAYA!
itemCount: 1000,
itemBuilder: (context, index) => ListTile(title: Text('$index')),
),
],
)
ListView dan GridView yang diatur ke NeverScrollableScrollPhysics dan shrinkWrap: true akan membangun semua item sekaligus. Ini bisa menyebabkan bottleneck performa dengan dataset yang besar.
// SOLUSI yang benar: gunakan CustomScrollView dengan Sliver
CustomScrollView(
slivers: [
SliverToBoxAdapter(child: HeaderWidget()),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => ListTile(title: Text('$index')),
childCount: 1000,
),
),
SliverToBoxAdapter(child: FooterWidget()),
],
)
Tips Performa Scrolling #
// 1. Gunakan itemExtent jika tinggi item tetap
ListView.builder(
itemExtent: 72, // O(1) scroll position calculation
itemBuilder: ...,
)
// 2. Gunakan const untuk widget item yang statis
ListView.builder(
itemBuilder: (context, index) => const StaticItem(), // const!
)
// 3. RepaintBoundary untuk item dengan animasi
ListView.builder(
itemBuilder: (context, index) => RepaintBoundary(
child: AnimatedItem(item: items[index]),
),
)
// 4. Hindari nested scrollable tanpa ukuran tetap
// SALAH: Column > ListView tanpa Expanded
// BENAR: CustomScrollView dengan SliverList
// 5. Gunakan cacheExtent untuk pre-load item
ListView.builder(
cacheExtent: 500, // pre-build item 500px di luar viewport
itemBuilder: ...,
)
Ringkasan #
- Semua scrolling Flutter dibangun di atas sistem Sliver — ListView, GridView, PageView semuanya adalah CustomScrollView dengan satu Sliver di dalamnya.
ListView.builderadalah pilihan default untuk list — item dibuat lazy saat masuk viewport. GunakanitemExtentuntuk performa optimal jika tinggi item tetap.GridView.builderuntuk layout grid 2D. GunakanSliverGridDelegateWithMaxCrossAxisExtentuntuk grid responsif yang menyesuaikan jumlah kolom otomatis.CustomScrollViewmemungkinkan kombinasi SliverAppBar, SliverList, SliverGrid, dan SliverToBoxAdapter dalam satu scrollable — gunakan ini alih-alihshrinkWrap: true.SliverAppBardenganpinned,floating, dansnapmenciptakan efek collapsing app bar tanpa kode tambahan.SliverFixedExtentListjauh lebih performan dariSliverListbiasa jika semua item memiliki tinggi yang sama.- Hindari
shrinkWrap: true+NeverScrollableScrollPhysics— kombinasi ini membangun semua item sekaligus dan membunuh performa untuk list besar.- Gunakan
ScrollControlleruntuk scrolling programatik (animateTo,jumpTo) dan mendengarkan posisi scroll untuk fitur seperti “back to top” button.