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'),
      ),
    ],
  ),
)
Hindari SingleChildScrollView dengan Column berisi list besar. Column membuat semua children sekaligus — tidak ada lazy loading. Untuk list besar, selalu gunakan ListView.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.builder adalah pilihan default untuk list — item dibuat lazy saat masuk viewport. Gunakan itemExtent untuk performa optimal jika tinggi item tetap.
  • GridView.builder untuk layout grid 2D. Gunakan SliverGridDelegateWithMaxCrossAxisExtent untuk grid responsif yang menyesuaikan jumlah kolom otomatis.
  • CustomScrollView memungkinkan kombinasi SliverAppBar, SliverList, SliverGrid, dan SliverToBoxAdapter dalam satu scrollable — gunakan ini alih-alih shrinkWrap: true.
  • SliverAppBar dengan pinned, floating, dan snap menciptakan efek collapsing app bar tanpa kode tambahan.
  • SliverFixedExtentList jauh lebih performan dari SliverList biasa jika semua item memiliki tinggi yang sama.
  • Hindari shrinkWrap: true + NeverScrollableScrollPhysics — kombinasi ini membangun semua item sekaligus dan membunuh performa untuk list besar.
  • Gunakan ScrollController untuk scrolling programatik (animateTo, jumpTo) dan mendengarkan posisi scroll untuk fitur seperti “back to top” button.

← Sebelumnya: Layout   Berikutnya: Input & Forms →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact