Scrolling #
Scrolling adalah salah satu interaksi paling mendasar dan krusial dalam antarmuka aplikasi modern. Di Flutter, sistem scrolling dirancang dengan tingkat fleksibilitas dan kinerja tinggi untuk mendukung berbagai skenario interaksi — mulai dari daftar kontak sederhana hingga efek transisi kompleks seperti kolapsnya bilah aplikasi (collapsing app bar), efek paralaks (parallax), serta penggabungan dinamis antara kisi (grid) dan daftar linear. Seluruh ekosistem scrolling di Flutter dibangun di atas fondasi arsitektur Sliver yang sangat teroptimasi. Kita akan membedah arsitektur ini secara mendalam agar kita dapat membangun antarmuka yang mulus, hemat memori, dan bebas dari kendala penurunan performa (jank).
Arsitektur Scrolling Flutter #
Untuk menggunakan widget scrolling secara optimal, kita harus memahami bagaimana Flutter merender elemen yang dapat digulir di balik layar. Aliran data dan komponen tata letak scrolling Flutter disusun berdasarkan struktur berlapis sebagai berikut:
flowchart TD
ScrollView["ScrollView (ListView / GridView)"] --> Scrollable["Scrollable (Gesture & Physics)"]
Scrollable --> Viewport["Viewport (Window View)"]
Viewport --> Slivers["Sliver(s) (Dynamic Rendering)"]
Slivers --> Children["Children Widgets"]Sistem scrolling ini terdiri dari beberapa komponen utama yang bekerja secara sinergis:
- Scrollable: Komponen non-visual yang bertugas mendeteksi gesture masukan dari pengguna (seperti geseran jari atau lemparan akselerasi/fling) dan menerapkannya ke dalam perhitungan fisika gulir melalui kelas
ScrollPhysics. - Viewport: Jendela visual virtual yang menentukan area layar mana yang dapat dilihat oleh pengguna. Viewport bertindak sebagai pembatas dimensi ruang tayang.
- Sliver: Unit tata letak khusus yang dirancang untuk bekerja secara efisien di dalam Viewport. Berbeda dengan widget biasa yang menggunakan
RenderBox(tata letak kotak dua dimensi statis), Sliver menggunakanRenderSliveryang memungkinkan kalkulasi ukuran berdasarkan berapa banyak bagian dari dirinya yang sedang terlihat di dalam Viewport (misalnya menghitung offset gulir dan sisa ruang tayang). - Lazy Loading (Pemuatan Lambat): Ini adalah fitur terpenting dari arsitektur scrolling Flutter. Hanya item yang masuk atau hampir masuk ke dalam area Viewport yang akan di-build dan di-render. Item yang digulir keluar dari Viewport secara otomatis akan dibongkar (destroy) dari memori atau disimpan dalam cache sementara demi menghemat sumber daya sistem.
ListView — List Vertikal/Horizontal #
ListView adalah widget paling mendasar untuk menampilkan sekumpulan elemen linear secara searah (vertikal maupun horizontal).
1. Constructor Standar (ListView) #
Constructor default ini cocok digunakan untuk daftar item yang pendek dan nilainya sudah pasti (statis). Di bawah kap, constructor ini langsung merender seluruh widget anak secara sekaligus, tanpa fitur pemuatan lambat.
ListView(
padding: const EdgeInsets.all(16.0),
children: [
ListTile(
leading: const Icon(Icons.payment),
title: const Text('Metode Pembayaran'),
trailing: const Icon(Icons.chevron_right),
onTap: () {},
),
ListTile(
leading: const Icon(Icons.security),
title: const Text('Keamanan Akun'),
trailing: const Icon(Icons.chevron_right),
onTap: () {},
),
const Divider(),
ListTile(
leading: const Icon(Icons.help),
title: const Text('Pusat Bantuan'),
trailing: const Icon(Icons.chevron_right),
onTap: () {},
),
],
)
2. ListView.builder (Lazy Loading Dinamis) #
Constructor ini wajib digunakan ketika kita memiliki data yang panjang, tidak terbatas (infinite), atau dinamis dari API. Item hanya akan dibuat ketika akan mendekati Viewport.
ListView.builder(
itemCount: produkList.length,
// itemExtent: Membantu Flutter menghitung posisi scroll secara O(1)
itemExtent: 80.0,
itemBuilder: (BuildContext context, int index) {
final produk = produkList[index];
return ListTile(
key: ValueKey(produk.id),
title: Text(produk.nama),
subtitle: Text('Stok: ${produk.stok}'),
trailing: Text('Rp ${produk.harga}'),
);
},
)
3. ListView.separated (Daftar dengan Separator Otomatis) #
Jika kita membutuhkan garis pemisah atau widget dekoratif di sela-sela item daftar, constructor .separated adalah solusi paling bersih karena ia tidak akan merender pemisah di sebelum item pertama atau setelah item terakhir.
ListView.separated(
itemCount: users.length,
separatorBuilder: (BuildContext context, int index) {
return const Divider(
height: 1.0,
color: Colors.grey,
);
},
itemBuilder: (BuildContext context, int index) {
return ListTile(
title: Text(users[index].username),
subtitle: Text(users[index].email),
);
},
)
4. ListView Horizontal #
Kita cukup mengubah properti scrollDirection untuk membuat daftar yang bergulir ke samping. Pastikan widget anak memiliki lebar yang ditentukan agar tidak memicu kesalahan tata letak.
SizedBox(
height: 60.0,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: kategoriList.length,
itemBuilder: (BuildContext context, int index) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Chip(
label: Text(kategoriList[index]),
),
);
},
),
)
GridView — Layout Grid 2D #
GridView digunakan untuk merender anak-anak dalam bentuk kisi/kotak dua dimensi (baris dan kolom).
1. GridView.count (Jumlah Kolom Statis) #
Kita mendefinisikan jumlah kolom secara eksplisit menggunakan properti crossAxisCount.
GridView.count(
crossAxisCount: 3, // Mengunci layar agar selalu berisi 3 kolom
crossAxisSpacing: 10.0,
mainAxisSpacing: 10.0,
childAspectRatio: 1.0, // Perbandingan lebar:tinggi item (1.0 = persegi)
children: List.generate(9, (index) {
return Container(
color: Colors.blue[(index + 1) * 100],
child: Center(child: Text('Item $index')),
);
}),
)
2. GridView.builder (Pemuatan Grid Dinamis) #
Mirip seperti ListView.builder, constructor ini sangat penting untuk grid dengan jumlah data besar. Kita harus menyediakan parameter gridDelegate.
GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 8.0,
mainAxisSpacing: 8.0,
childAspectRatio: 0.75, // Tinggi item lebih panjang dari lebarnya
),
itemCount: katalogProduk.length,
itemBuilder: (BuildContext context, int index) {
return Card(
child: Column(
children: [
Expanded(
child: Image.network(katalogProduk[index].gambarUrl, fit: BoxFit.cover),
),
Text(katalogProduk[index].nama),
],
),
);
},
)
3. Grid Responsif Menggunakan MaxCrossAxisExtent #
Jika kita ingin agar jumlah kolom menyesuaikan secara otomatis dengan lebar layar perangkat (misalnya, 2 kolom di ponsel, 4 kolom di tablet kecil, dan 6 kolom di monitor desktop), gunakan SliverGridDelegateWithMaxCrossAxisExtent.
GridView.builder(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 180.0, // Lebar maksimal setiap tile adalah 180px
crossAxisSpacing: 12.0,
mainAxisSpacing: 12.0,
childAspectRatio: 1.0,
),
itemCount: galeriFoto.length,
itemBuilder: (BuildContext context, int index) {
return Image.network(galeriFoto[index].url, fit: BoxFit.cover);
},
)
SingleChildScrollView — Konten yang Bisa Di-scroll #
SingleChildScrollView digunakan untuk membungkus satu widget tunggal (biasanya berupa Column atau Form) agar kontennya dapat digulir jika ukuran konten tersebut melebihi batas layar fisik. Widget ini sangat sering digunakan pada layar pengisian formulir atau halaman detail artikel statis.
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(20.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text('Form Registrasi', style: TextStyle(fontSize: 24.0, fontWeight: FontWeight.bold)),
const SizedBox(height: 20.0),
TextFormField(decoration: const InputDecoration(labelText: 'Nama Lengkap')),
const SizedBox(height: 16.0),
TextFormField(decoration: const InputDecoration(labelText: 'Alamat Email')),
const SizedBox(height: 16.0),
TextFormField(decoration: const InputDecoration(labelText: 'Nomor Telepon')),
const SizedBox(height: 16.0),
TextFormField(decoration: const InputDecoration(labelText: 'Password')),
const SizedBox(height: 32.0),
ElevatedButton(
onPressed: () {},
child: const Text('Daftar Akun'),
),
],
),
),
),
);
}
Aturan Utama: Jangan pernah membungkus daftar besar dinamis (sepertiListView.builder) di dalamColumnyang diletakkan di dalamSingleChildScrollViewkecuali jika kita menghentikan scroll internal list tersebut secara total. Hal ini menghilangkan kegunaan lazy loading dan memicu konsumsi memori yang sangat boros karena Flutter terpaksa merender seluruh item sekaligus di luar layar.
PageView — Scrolling Per Halaman #
PageView memungkinkan pengguna untuk menggulir halaman secara penuh layar per layar, baik secara horizontal (bawaan) maupun vertikal. Skenario umum penggunaan widget ini meliputi pembuatan layar perkenalan aplikasi (onboarding), galeri foto geser, atau tayangan video pendek.
Berikut adalah contoh implementasi PageView lengkap dengan kontrol tombol navigasi dan indikator titik halaman:
class OnboardingWidget extends StatefulWidget {
const OnboardingWidget({super.key});
@override
State<OnboardingWidget> createState() => _OnboardingWidgetState();
}
class _OnboardingWidgetState extends State<OnboardingWidget> {
final PageController _pageController = PageController();
int _currentPageIndex = 0;
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(
alignment: Alignment.bottomCenter,
children: [
PageView(
controller: _pageController,
onPageChanged: (int index) {
setState(() {
_currentPageIndex = index;
});
},
children: [
Container(color: Colors.teal, child: const Center(child: Text('Halaman 1'))),
Container(color: Colors.deepOrange, child: const Center(child: Text('Halaman 2'))),
Container(color: Colors.indigo, child: const Center(child: Text('Halaman 3'))),
],
),
Positioned(
bottom: 40.0,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(3, (index) {
return AnimatedContainer(
duration: const Duration(milliseconds: 250),
margin: const EdgeInsets.symmetric(horizontal: 6.0),
width: _currentPageIndex == index ? 24.0 : 8.0,
height: 8.0,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4.0),
color: _currentPageIndex == index ? Colors.white : Colors.white54,
),
);
}),
),
),
],
),
);
}
}
ScrollController — Kontrol Programatik & Infinite Scroll #
ScrollController digunakan untuk memantau aktivitas gulir secara langsung, mendeteksi posisi offset gulir saat ini, serta memicu perpindahan posisi gulir secara terprogram.
Salah satu implementasi paling umum dari ScrollController di aplikasi nyata adalah pembuatan fitur Infinite Scroll (Pemuatan Data Otomatis di Bawah/Lazy Pagination).
Berikut adalah contoh implementasi lengkap mekanisme pagination otomatis:
class InfiniteScrollList extends StatefulWidget {
const InfiniteScrollList({super.key});
@override
State<InfiniteScrollList> createState() => _InfiniteScrollListState();
}
class _InfiniteScrollListState extends State<InfiniteScrollList> {
final ScrollController _scrollController = ScrollController();
final List<String> _items = List.generate(20, (index) => 'Item Awal #${index + 1}');
bool _isLoadingMore = false;
@override
void initState() {
super.initState();
_scrollController.addListener(_onScrollChanged);
}
void _onScrollChanged() {
// Periksa apakah posisi scroll saat ini sudah mendekati ujung bawah daftar
if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) {
_fetchMoreData();
}
}
Future<void> _fetchMoreData() async {
if (_isLoadingMore) return;
setState(() {
_isLoadingMore = true;
});
// Simulasi pemanggilan API selama 2 detik
await Future.delayed(const Duration(seconds: 2));
final int currentLength = _items.length;
setState(() {
_items.addAll(List.generate(10, (index) => 'Item Baru #${currentLength + index + 1}'));
_isLoadingMore = false;
});
}
void _scrollToTop() {
_scrollController.animateTo(
0.0,
duration: const Duration(milliseconds: 500),
curve: Curves.easeOutCubic,
);
}
@override
void dispose() {
_scrollController.removeListener(_onScrollChanged);
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Lazy Pagination')),
body: ListView.builder(
controller: _scrollController,
itemCount: _items.length + (_isLoadingMore ? 1 : 0),
itemBuilder: (BuildContext context, int index) {
if (index < _items.length) {
return ListTile(title: Text(_items[index]));
} else {
return const Padding(
padding: EdgeInsets.all(16.0),
child: Center(child: CircularProgressIndicator()),
);
}
},
),
floatingActionButton: FloatingActionButton(
onPressed: _scrollToTop,
child: const Icon(Icons.arrow_upward),
),
);
}
}
Sistem Sliver — Fondasi Semua Scrolling #
Sistem Sliver adalah fondasi di mana seluruh fungsi scrolling di Flutter diimplementasikan. Sliver mewakili bagian dari area scroll yang dapat dikonfigurasi untuk berperilaku secara khusus guna mencapai efek visual transisi yang mulus.
CustomScrollView adalah wadah utama yang digunakan untuk menggabungkan berbagai macam komponen Sliver menjadi satu kesatuan alur gulir linear:
flowchart TD
CustomScrollView["CustomScrollView"] --> SliverAppBar["SliverAppBar (Collapsing Head)"]
CustomScrollView --> SliverToBoxAdapter["SliverToBoxAdapter (Static Widget)"]
CustomScrollView --> SliverList["SliverList (Vertical List)"]
CustomScrollView --> SliverGrid["SliverGrid (2D Grid)"]
CustomScrollView --> SliverFillRemaining["SliverFillRemaining (Viewport Filler)"]Mari kita bahas komponen-komponen Sliver esensial yang paling sering digunakan dalam pengembangan aplikasi profesional:
1. SliverAppBar (Navigasi Dinamis) #
SliverAppBar dapat menciut (collapse), melebar (expand), atau menghilang secara dinamis seiring arah geseran layar. Properti kuncinya meliputi:
pinned: Menyimpan App Bar agar tetap menempel di bagian paling atas layar saat data di-scroll.floating: Memunculkan kembali App Bar begitu pengguna mendeteksi gestur usap ke atas sedikit saja, tanpa perlu kembali ke bagian paling atas daftar.snap: Menggabungkan fungsifloatingagar App Bar langsung meluncur penuh terbuka/tertutup secara otomatis berdasarkan arah gesekan pendek.
SliverAppBar(
expandedHeight: 250.0,
pinned: true,
flexibleSpace: FlexibleSpaceBar(
title: const Text('Profil Pengguna'),
background: Image.network(
'https://example.com/banner.jpg',
fit: BoxFit.cover,
),
),
)
2. SliverToBoxAdapter (Jembatan Widget Biasa) #
Widget non-Sliver standar (seperti Container, Padding, atau Card) tidak dapat diletakkan secara langsung di dalam parameter slivers pada CustomScrollView. Kita wajib membungkus widget biasa tersebut menggunakan SliverToBoxAdapter agar dapat berpartisipasi dengan aman di dalam alur scrolling Sliver.
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Card(
child: ListTile(
title: const Text('Informasi Promo'),
subtitle: const Text('Diskon hingga 50% minggu ini saja!'),
),
),
),
)
3. SliverList & SliverGrid (Sliver Dinamis) #
SliverList dan SliverGrid adalah versi Sliver dari ListView dan GridView. Keduanya menggunakan delegasi SliverChildDelegate untuk memfasilitasi pembuatan item secara dinamis.
SliverList(
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
return ListTile(title: Text('Daftar Item #$index'));
},
childCount: 50,
),
)
4. SliverFixedExtentList (Optimasi Kinerja Tinggi) #
Jika setiap baris dalam daftar kita memiliki ukuran tinggi pixel yang sama persis (misalnya, seluruh tinggi daftar bertipe ListTile adalah 72.0 piksel), kita sangat disarankan menggunakan SliverFixedExtentList.
SliverFixedExtentList(
itemExtent: 72.0, // Mengunci tinggi tiap baris secara statis
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
return ListTile(title: Text('Sliver Fixed Item #$index'));
},
childCount: 1000,
),
)
Dengan memberikan nilai itemExtent, Flutter tidak perlu membuang daya pemrosesan CPU untuk melakukan kalkulasi ukuran layout (layout measurement) terhadap widget anak. Flutter dapat menghitung posisi offset awal dari setiap item secara langsung lewat persamaan matematika sederhana berkecepatan $O(1)$.
Anti-Pattern: shrinkWrap dan NeverScrollableScrollPhysics #
Salah satu kesalahan arsitektural yang paling sering kita temui dalam basis kode Flutter pemula adalah mematikan fungsi scroll internal daftar dan memaksa daftar tersebut menghitung ukuran total dirinya di dalam kontainer yang tidak membatasi ukuran (seperti Column).
// ANTI-PATTERN: Merusak performa aplikasi
Widget build(BuildContext context) {
return SingleChildScrollView(
child: Column(
children: [
const HeaderWidget(),
ListView.builder(
shrinkWrap: true, // BAHAYA: Memaksa ListView mengukur tinggi total seluruh item
physics: const NeverScrollableScrollPhysics(), // BAHAYA: Menonaktifkan mekanisme scroll internal
itemCount: 1000,
itemBuilder: (context, index) => ListTile(title: Text('Data #$index')),
),
],
),
);
}
Mengapa Kombinasi Ini Sangat Berbahaya? #
- Kehilangan Fitur Lazy Loading: Ketika kita menyetel
shrinkWrap: true,ListViewterpaksa menghitung seluruh tinggi konten dari total 1000 item agar bisa dilaporkan ke kontainer induknya. Akibatnya, Flutter akan mem-build dan me-lay out seluruh 1000 widget tersebut sekaligus di memori saat halaman pertama kali dibuka, meskipun pengguna hanya melihat 5 item pertama di layar ponselnya. Hal ini membuang memori RAM dan memperlambat render frame awal aplikasi secara dramatis. - Dua Kali Pemrosesan Scroll: Sistem mendeteksi geseran lewat
SingleChildScrollViewlalu meneruskannya ke widget di dalamnya secara tidak efisien.
Solusi Bersih Menggunakan CustomScrollView #
Alih-akhir membungkus daftar di dalam SingleChildScrollView dan Column, kita harus memigrasikan struktur antarmuka kita sepenuhnya menjadi satu kesatuan CustomScrollView yang memanfaatkan kekuatan penuh Sliver:
// SOLUSI TERBAIK: Menggunakan CustomScrollView & Sliver
Widget build(BuildContext context) {
return CustomScrollView(
slivers: [
// Bungkus header statis menggunakan SliverToBoxAdapter
const SliverToBoxAdapter(
child: HeaderWidget(),
),
// Gunakan SliverList untuk menampilkan data dinamis secara lambat (lazy)
SliverList(
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
return ListTile(title: Text('Data Teroptimasi #$index'));
},
childCount: 1000,
),
),
],
);
}
Tips Performa Scrolling #
Untuk menjaga agar visual antarmuka aplikasi kita tetap berada di angka 60 FPS atau 120 FPS pada layar dengan high-refresh-rate, ikuti panduan optimasi performa berikut:
- Tentukan Dimensi Ukuran Lebih Awal: Gunakan properti
itemExtentatauprototypeItempadaListViewuntuk membebaskan engine rendering dari proses kalkulasi dimensi anak secara berulang. - Optimasi Struktur Subtree dengan Const: Terapkan konstruktor
constpada widget dekorasi statis di dalam daftar builder. Hal ini memotong beban kerja Flutter dalam membandingkan perbedaan pohon widget (widget diffing) saat terjadi rekonstruksi layar. - Penyekatan Area Gambar Ulang dengan RepaintBoundary: Jika di dalam daftar item kita terdapat animasi dinamis atau widget kustom yang sering diperbarui (seperti indikator progress unduhan), bungkus widget tersebut dengan
RepaintBoundary. Ini memisahkan lapisan gambar (painting layer) item tersebut dari bagian daftar lainnya, sehingga Flutter hanya menggambar ulang komponen yang beranimasi saja tanpa memproses ulang seluruh daftar. - Konfigurasi Cache Viewport Secara Bijak: Kita bisa menyetel properti
cacheExtentpada widget gulir. Nilai bawaannya berkisar pada area 250px sebelum dan sesudah area tayang aktif. Menyetel angka ini sedikit lebih tinggi dapat memperlancar rendering gambar yang kompleks agar sudah siap sebelum digulir masuk oleh pengguna, namun jangan terlalu besar karena akan meningkatkan beban alokasi memori.
ListView.builder(
cacheExtent: 400.0, // Mem-build item 400px lebih awal sebelum masuk viewport
itemBuilder: (BuildContext context, int index) {
return RepaintBoundary(
child: ComplexAnimatedItem(index: index),
);
},
)
Ringkasan #
- Satu Ekosistem Sliver: Semua widget scrolling di Flutter (seperti ListView dan GridView) merupakan implementasi terbungkus dari widget
CustomScrollViewyang berisi kumpulan komponen Sliver.- Gunakan ListView.builder: Hindari memuat data dinamis dalam constructor default
ListView. Manfaatkan builder untuk mengaktifkan pemuatan lambat (lazy loading) otomatis demi menghemat RAM.- Beralih dari shrinkWrap: Jauhi pola kombinasi
shrinkWrap: truedanNeverScrollableScrollPhysicsuntuk list besar. Gantilah dengan strukturCustomScrollViewandSliverListagar performa scrolling tetap mulus.- Gunakan ScrollController: Pantau pergeseran piksel secara presisi untuk memicu aksi terprogram (seperti memuat data halaman berikutnya saat mendekati ujung bawah halaman).
- Kunci itemExtent: Percepat perenderaan list dengan memberikan ukuran dimensi pasti secara statis melalui
itemExtentketika semua baris memiliki tinggi yang seragam.