Skip to content

Bab 8 — List, Grid & Data Berulang

Hampir setiap aplikasi menampilkan daftar: daftar pesan, produk, transaksi, kontak. Bab ini membahas cara melakukannya dengan benar — termasuk perbedaan yang sangat penting antara ListView biasa dan ListView.builder, yang menentukan apakah aplikasimu tetap lancar ketika datanya bertambah.

Column bukan untuk daftar panjang

Godaan pertama adalah memakai Column:

dart
Column(
  children: daftarProduk.map((p) => KartuProduk(produk: p)).toList(),
)

Ini bekerja untuk lima item. Untuk lima ratus, dua hal terjadi: isinya meluber melewati layar dan memicu peringatan overflow, dan seluruh lima ratus widget dibangun sekaligus meskipun hanya delapan yang terlihat.

ListView: bergulir, tetapi masih membangun semuanya

dart
ListView(
  padding: const EdgeInsets.all(16),
  children: [
    const Text('Judul'),
    for (final produk in daftarProduk) KartuProduk(produk: produk),
  ],
)

Sekarang isinya bisa digulir. Tetapi masalah kedua belum selesai: ListView dengan children tetap membangun semua anaknya sekaligus.

Gunakan bentuk ini hanya kalau jumlah itemnya sedikit dan tetap — misalnya menu pengaturan dengan sepuluh baris.

ListView.builder: hanya membangun yang terlihat

Ini bentuk yang seharusnya kamu pakai untuk daftar dinamis.

dart
ListView.builder(
  itemCount: daftarProduk.length,
  itemBuilder: (ctx, indeks) {
    return KartuProduk(produk: daftarProduk[indeks]);
  },
)

Perbedaannya fundamental. itemBuilder adalah fungsi yang dipanggil hanya ketika sebuah item akan terlihat. Item di luar layar tidak dibangun sama sekali, dan item yang sudah tergulir jauh ke atas dibuang dari memori.

      ListView (children)              ListView.builder
   ┌──────────────────────┐         ┌──────────────────────┐
   │ ✓ item 0  dibangun   │         │ ✓ item 0  dibangun   │
   │ ✓ item 1  dibangun   │         │ ✓ item 1  dibangun   │
   ├──────────────────────┤ layar   ├──────────────────────┤ layar
   │ ✓ item 2  dibangun   │         │ ✓ item 2  dibangun   │
   │ ✓ item 3  dibangun   │         │ ✓ item 3  dibangun   │
   ├──────────────────────┤         ├──────────────────────┤
   │ ✓ item 4  dibangun   │         │ ✗ item 4  dilewati   │
   │ ✓ item 5  dibangun   │         │ ✗ item 5  dilewati   │
   │ ✓ ...                │         │ ✗ ...                │
   │ ✓ item 999 dibangun  │         │ ✗ item 999 dilewati  │
   └──────────────────────┘         └──────────────────────┘
      1000 widget dibuat               ~6 widget dibuat

Untuk daftar seribu item, perbedaannya adalah antara aplikasi yang tersendat dan aplikasi yang mulus.

Aturan sederhana

Jumlah item tetap dan sedikit (di bawah ~20, tidak berubah)? ListView biasa boleh. Jumlah item dinamis — dari API, database, atau input pengguna? Selalu ListView.builder.

Properti yang berguna

dart
ListView.builder(
  itemCount: daftarProduk.length,
  padding: const EdgeInsets.symmetric(vertical: 8),
  physics: const BouncingScrollPhysics(),   // gaya pantulan iOS
  shrinkWrap: false,
  itemBuilder: (ctx, indeks) => KartuProduk(produk: daftarProduk[indeks]),
)

shrinkWrap bukan solusi ajaib

Ketika kamu menaruh ListView di dalam Column, Flutter melempar error "unbounded height". Banyak orang menambahkan shrinkWrap: true untuk mendiamkannya — tetapi itu membuang seluruh keuntungan builder, karena ListView harus mengukur semua anaknya untuk tahu tingginya.

Gunakan Expanded sebagai gantinya:

dart
// ❌ Membangun semua item
Column(
  children: [
    const Text('Judul'),
    ListView.builder(shrinkWrap: true, /* ... */),
  ],
)

// ✅ Tetap malas dan efisien
Column(
  children: [
    const Text('Judul'),
    Expanded(
      child: ListView.builder(/* ... */),
    ),
  ],
)

ListView.separated

Kalau kamu butuh pemisah antar item:

dart
ListView.separated(
  itemCount: daftarKontak.length,
  separatorBuilder: (ctx, indeks) => const Divider(height: 1),
  itemBuilder: (ctx, indeks) => ListTile(
    leading: CircleAvatar(child: Text(daftarKontak[indeks].inisial)),
    title: Text(daftarKontak[indeks].nama),
  ),
)

Lebih rapi daripada menyisipkan Divider secara manual, karena pemisah tidak muncul setelah item terakhir.

Daftar bersarang: list di dalam list

Pola ini sering muncul dan layak diperhatikan: sebuah daftar yang setiap itemnya mengandung daftar lain.

Contohnya grafik pengeluaran per kategori, di mana setiap batang mewakili satu kategori:

dart
// lib/widgets/grafik/grafik.dart
import 'package:flutter/material.dart';
import '../../models/pengeluaran.dart';
import 'batang.dart';

class Grafik extends StatelessWidget {
  const Grafik({super.key, required this.pengeluaran});

  final List<Pengeluaran> pengeluaran;

  /// Membuat satu ember per kategori, masing-masing berisi
  /// pengeluaran yang termasuk kategori itu.
  List<EmberPengeluaran> get emberKategori {
    return [
      for (final kategori in Kategori.values)
        EmberPengeluaran.untukKategori(pengeluaran, kategori),
    ];
  }

  /// Nilai tertinggi dipakai sebagai acuan tinggi batang.
  double get pengeluaranMaksimum {
    var maksimum = 0.0;
    for (final ember in emberKategori) {
      if (ember.totalPengeluaran > maksimum) {
        maksimum = ember.totalPengeluaran;
      }
    }
    return maksimum;
  }

  @override
  Widget build(BuildContext context) {
    final skema = Theme.of(context).colorScheme;

    return Container(
      margin: const EdgeInsets.all(16),
      padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 8),
      width: double.infinity,
      height: 180,
      decoration: BoxDecoration(
        borderRadius: const BorderRadius.all(Radius.circular(12)),
        gradient: LinearGradient(
          colors: [
            skema.primary.withValues(alpha: 0.3),
            skema.primary.withValues(alpha: 0.0),
          ],
          begin: Alignment.bottomCenter,
          end: Alignment.topCenter,
        ),
      ),
      child: Column(
        children: [
          Expanded(
            child: Row(
              crossAxisAlignment: CrossAxisAlignment.end,
              children: [
                // Daftar pertama: satu batang per ember
                for (final ember in emberKategori)
                  Batang(
                    isian: pengeluaranMaksimum == 0
                        ? 0
                        : ember.totalPengeluaran / pengeluaranMaksimum,
                  ),
              ],
            ),
          ),
          const SizedBox(height: 12),
          Row(
            children: [
              // Daftar kedua: satu ikon per ember
              for (final ember in emberKategori)
                Expanded(
                  child: Padding(
                    padding: const EdgeInsets.symmetric(horizontal: 4),
                    child: Icon(
                      ember.kategori.ikon,
                      color: skema.onSurface.withValues(alpha: 0.7),
                    ),
                  ),
                ),
            ],
          ),
        ],
      ),
    );
  }
}

Dan kelas ember yang mengelompokkan datanya:

dart
// lib/models/ember_pengeluaran.dart
import 'pengeluaran.dart';

class EmberPengeluaran {
  const EmberPengeluaran({
    required this.kategori,
    required this.pengeluaran,
  });

  /// Konstruktor bernama yang menyaring sekaligus mengelompokkan.
  EmberPengeluaran.untukKategori(
    List<Pengeluaran> semuaPengeluaran,
    this.kategori,
  ) : pengeluaran = semuaPengeluaran
            .where((p) => p.kategori == kategori)
            .toList();

  final Kategori kategori;
  final List<Pengeluaran> pengeluaran;

  double get totalPengeluaran {
    return pengeluaran.fold<double>(0, (jumlah, p) => jumlah + p.jumlah);
  }
}

Batang tunggalnya:

dart
// lib/widgets/grafik/batang.dart
import 'package:flutter/material.dart';

class Batang extends StatelessWidget {
  const Batang({super.key, required this.isian});

  /// Nilai antara 0.0 dan 1.0.
  final double isian;

  @override
  Widget build(BuildContext context) {
    return Expanded(
      child: Padding(
        padding: const EdgeInsets.symmetric(horizontal: 4),
        child: FractionallySizedBox(
          heightFactor: isian,
          child: DecoratedBox(
            decoration: BoxDecoration(
              shape: BoxShape.rectangle,
              borderRadius: const BorderRadius.vertical(
                top: Radius.circular(8),
              ),
              color: Theme.of(context).colorScheme.primary,
            ),
          ),
        ),
      ),
    );
  }
}

FractionallySizedBox dengan heightFactor adalah cara elegan membuat batang proporsional tanpa menghitung piksel — dibahas lagi di Bab 10.

GridView: daftar dua dimensi

Untuk menampilkan item dalam kisi:

dart
GridView(
  padding: const EdgeInsets.all(24),
  gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
    crossAxisCount: 2,          // dua kolom
    childAspectRatio: 3 / 2,    // lebar : tinggi tiap sel
    crossAxisSpacing: 20,       // jarak horizontal
    mainAxisSpacing: 20,        // jarak vertikal
  ),
  children: [
    for (final kategori in daftarKategori)
      ItemKategori(kategori: kategori),
  ],
)

Sama seperti ListView, ada versi builder yang jauh lebih efisien:

dart
GridView.builder(
  padding: const EdgeInsets.all(24),
  gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
    crossAxisCount: 2,
    childAspectRatio: 3 / 2,
    crossAxisSpacing: 20,
    mainAxisSpacing: 20,
  ),
  itemCount: daftarKategori.length,
  itemBuilder: (ctx, indeks) => ItemKategori(
    kategori: daftarKategori[indeks],
    saatDipilih: () => _pilihKategori(context, daftarKategori[indeks]),
  ),
)

Dua jenis gridDelegate

SliverGridDelegateWithFixedCrossAxisCount — jumlah kolom tetap. Cocok kalau kamu tahu persis mau berapa kolom.

SliverGridDelegateWithMaxCrossAxisExtent — lebar maksimum tiap sel. Jumlah kolom dihitung otomatis dari lebar layar. Ini pilihan yang lebih baik untuk aplikasi yang harus bekerja di ponsel dan tablet:

dart
GridView.builder(
  gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
    maxCrossAxisExtent: 200,    // tiap sel maksimal 200 piksel
    childAspectRatio: 3 / 2,
    crossAxisSpacing: 16,
    mainAxisSpacing: 16,
  ),
  itemCount: daftarKategori.length,
  itemBuilder: (ctx, i) => ItemKategori(kategori: daftarKategori[i]),
)

Di ponsel selebar 400 piksel ini menghasilkan dua kolom; di tablet selebar 1000 piksel menjadi lima kolom — tanpa kode tambahan.

Contoh item kisi yang bisa diketuk:

dart
// lib/widgets/item_kategori.dart
import 'package:flutter/material.dart';
import '../models/kategori.dart';

class ItemKategori extends StatelessWidget {
  const ItemKategori({
    super.key,
    required this.kategori,
    required this.saatDipilih,
  });

  final Kategori kategori;
  final void Function() saatDipilih;

  @override
  Widget build(BuildContext context) {
    return InkWell(
      onTap: saatDipilih,
      splashColor: Theme.of(context).primaryColor,
      borderRadius: BorderRadius.circular(16),
      child: Container(
        padding: const EdgeInsets.all(16),
        decoration: BoxDecoration(
          borderRadius: BorderRadius.circular(16),
          gradient: LinearGradient(
            colors: [
              kategori.warna.withValues(alpha: 0.55),
              kategori.warna.withValues(alpha: 0.9),
            ],
            begin: Alignment.topLeft,
            end: Alignment.bottomRight,
          ),
        ),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Icon(kategori.ikon, size: 32, color: Colors.white),
            const SizedBox(height: 8),
            Text(
              kategori.judul,
              textAlign: TextAlign.center,
              style: Theme.of(context).textTheme.titleMedium!.copyWith(
                    color: Colors.white,
                    fontWeight: FontWeight.bold,
                  ),
            ),
          ],
        ),
      ),
    );
  }
}

Dismissible: geser untuk menghapus

Interaksi yang sudah menjadi standar: menggeser baris ke samping untuk menghapusnya.

dart
ListView.builder(
  itemCount: _pengeluaran.length,
  itemBuilder: (ctx, indeks) {
    final item = _pengeluaran[indeks];

    return Dismissible(
      // Key wajib dan harus unik per item — lihat Bab 12
      key: ValueKey(item.id),

      // Latar belakang yang terlihat saat digeser
      background: Container(
        color: Theme.of(context).colorScheme.error.withValues(alpha: 0.75),
        margin: const EdgeInsets.symmetric(horizontal: 16),
        alignment: Alignment.centerRight,
        padding: const EdgeInsets.only(right: 24),
        child: const Icon(Icons.delete, color: Colors.white),
      ),

      direction: DismissDirection.endToStart,   // hanya kanan ke kiri

      onDismissed: (arah) {
        _hapusPengeluaran(item, indeks);
      },

      child: KartuPengeluaran(pengeluaran: item),
    );
  },
)

Dan penghapusan yang bisa diurungkan:

dart
void _hapusPengeluaran(Pengeluaran item, int indeks) {
  setState(() {
    _pengeluaran.remove(item);
  });

  // Bersihkan snackbar sebelumnya agar tidak menumpuk
  ScaffoldMessenger.of(context).clearSnackBars();

  ScaffoldMessenger.of(context).showSnackBar(
    SnackBar(
      duration: const Duration(seconds: 4),
      content: const Text('Pengeluaran dihapus.'),
      action: SnackBarAction(
        label: 'Urungkan',
        onPressed: () {
          setState(() {
            // Kembalikan ke posisi semula, bukan ke akhir daftar
            _pengeluaran.insert(indeks, item);
          });
        },
      ),
    ),
  );
}

Key wajib pada Dismissible

Dismissible harus punya key yang unik dan stabil. Tanpa itu, Flutter tidak bisa membedakan item mana yang dihapus, dan kamu akan melihat item yang salah menghilang. Gunakan ID data, bukan indeks — indeks berubah setelah penghapusan. Alasan lengkapnya di Bab 12.

Untuk meminta konfirmasi sebelum menghapus:

dart
Dismissible(
  key: ValueKey(item.id),
  confirmDismiss: (arah) async {
    return await showDialog<bool>(
      context: context,
      builder: (ctx) => AlertDialog(
        title: const Text('Hapus item?'),
        content: Text('Hapus "${item.judul}" dari daftar?'),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(ctx, false),
            child: const Text('Batal'),
          ),
          TextButton(
            onPressed: () => Navigator.pop(ctx, true),
            child: const Text('Hapus'),
          ),
        ],
      ),
    ) ?? false;
  },
  onDismissed: (_) => _hapus(item),
  child: /* ... */,
)

RefreshIndicator: tarik untuk menyegarkan

dart
RefreshIndicator(
  onRefresh: () async {
    await _muatUlangData();
  },
  child: ListView.builder(
    itemCount: _data.length,
    itemBuilder: (ctx, i) => ListTile(title: Text(_data[i].judul)),
  ),
)

Fungsi onRefresh harus mengembalikan Future; indikator berputar sampai Future itu selesai.

Daftar kosong tapi ingin tetap bisa ditarik

RefreshIndicator hanya bekerja kalau anaknya bisa digulir. Kalau daftarnya kosong, tidak ada yang bisa digulir dan gestur tarik tidak terdeteksi. Solusinya, paksa fisika yang selalu bisa digulir:

dart
ListView(
  physics: const AlwaysScrollableScrollPhysics(),
  children: [
    SizedBox(
      height: MediaQuery.of(context).size.height * 0.7,
      child: const Center(child: Text('Belum ada data')),
    ),
  ],
)

Menyaring dan mengurutkan

Operasi list dari Bab 2 adalah alat utamamu di sini.

dart
class _DaftarMealState extends State<DaftarMeal> {
  String _kataKunci = '';
  UrutBerdasarkan _urutan = UrutBerdasarkan.nama;
  bool _hanyaFavorit = false;

  List<Meal> get _mealTampil {
    // 1. Mulai dari data lengkap
    var hasil = List<Meal>.from(widget.semuaMeal);

    // 2. Saring berdasarkan kata kunci
    if (_kataKunci.isNotEmpty) {
      final kunci = _kataKunci.toLowerCase();
      hasil = hasil
          .where((m) => m.judul.toLowerCase().contains(kunci))
          .toList();
    }

    // 3. Saring favorit
    if (_hanyaFavorit) {
      hasil = hasil.where((m) => m.favorit).toList();
    }

    // 4. Urutkan
    switch (_urutan) {
      case UrutBerdasarkan.nama:
        hasil.sort((a, b) => a.judul.compareTo(b.judul));
      case UrutBerdasarkan.durasi:
        hasil.sort((a, b) => a.durasi.compareTo(b.durasi));
      case UrutBerdasarkan.terbaru:
        hasil.sort((a, b) => b.dibuatPada.compareTo(a.dibuatPada));
    }

    return hasil;
  }

  @override
  Widget build(BuildContext context) {
    final meal = _mealTampil;

    return Column(
      children: [
        Padding(
          padding: const EdgeInsets.all(16),
          child: TextField(
            decoration: const InputDecoration(
              hintText: 'Cari menu...',
              prefixIcon: Icon(Icons.search),
              border: OutlineInputBorder(),
            ),
            onChanged: (nilai) => setState(() => _kataKunci = nilai),
          ),
        ),
        if (meal.isEmpty)
          const Expanded(
            child: Center(child: Text('Tidak ada hasil yang cocok.')),
          )
        else
          Expanded(
            child: ListView.builder(
              itemCount: meal.length,
              itemBuilder: (ctx, i) => ItemMeal(meal: meal[i]),
            ),
          ),
      ],
    );
  }
}

Perhatikan List<Meal>.from(...) di baris pertama. Ini membuat salinan; tanpa itu, sort() akan mengubah urutan list aslinya secara permanen — bug yang sangat mudah terlewat karena tidak menimbulkan error.

Perhatikan juga bahwa _mealTampil adalah getter, bukan variabel. Ia dihitung ulang setiap kali build() berjalan, sehingga selalu mencerminkan state terkini tanpa kamu perlu menyinkronkan apa pun secara manual.

Latihan Mandiri

Kerjakan salah satu, beberapa, atau semuanya secara berurutan untuk melatih pemahamanmu sampai benar-benar lekat.

Variasi 1: Daftar Tugas yang Bisa Digeser — ⭐⭐ · 45–60 menit

Tantangan: Buat daftar tugas yang bisa digeser untuk dihapus, dengan snackbar "Urungkan" yang benar-benar mengembalikan tugas ke posisi semula, bukan ke ujung daftar. Tambahkan kolom pencarian dan satu tombol untuk mengurutkan berdasarkan nama.

Bayangan tampilan:

text
╭───────────────────────────────────╮
│ 9:41                      ▂▄▆ 🔋   │
│  🔍 [Cari tugas...]  [Urutkan A-Z] │
│                                     │
│  ┌─────────────────────────────┐  │
│  │ ☐ Beli susu                  │  │
│  ├─────────────────────────────┤  │
│  │ ☐ Kirim laporan   ← geser    │  │
│  ├─────────────────────────────┤  │
│  │ ☐ Bayar listrik              │  │
│  └─────────────────────────────┘  │
│              ▬▬▬▬                  │
╰───────────────────────────────────╯

...tergeser hingga hilang → snackbar muncul di bawah:

text
┌───────────────────────────────────┐
│ "Kirim laporan" dihapus [Urungkan] │
└───────────────────────────────────┘

Tekan "Urungkan" → "Kirim laporan" kembali persis ke posisi ke-2, bukan nyelip di akhir daftar.

Kriteria selesai:

  • Memakai ListView.builder, bukan Column di dalam SingleChildScrollView.
  • Dismissible memakai key berbasis ID data, bukan indeks.
  • Menggeser tiga item berturut-turut dengan cepat, lalu menekan "Urungkan" pada masing-masing, mengembalikan ketiganya ke urutan yang benar.
  • Daftar yang tampil di layar dihitung lewat getter dari daftar asli plus kata kunci pencarian — bukan disimpan sebagai list kedua.

Petunjuk: Kunci berbasis indeks tampak bekerja sampai kamu menghapus item di tengah: item berikutnya mewarisi indeks yang sama, Flutter mengira itu widget yang sama, dan animasi hapusnya mengenai baris yang salah. Untuk mengembalikan ke posisi semula, kamu perlu menyimpan dua hal sebelum menghapus: itemnya dan indeksnya — lalu insert(indeksLama, item) saat diurungkan. Perhatikan bahwa sort() mengubah list aslinya, jadi salin dulu dengan List.from(). Dan kalau pencarian dan pengurutan sama-sama aktif, urutan penerapannya penting: saring dulu, baru urutkan.

Variasi 2: Galeri Foto Adaptif — ⭐⭐ · 30–45 menit

Tantangan: Bangun galeri gambar berbentuk kisi yang jumlah kolomnya menyesuaikan lebar layar secara otomatis, dengan gambar berbentuk persegi dan jarak yang konsisten. Tambahkan mode "tampilan besar" yang mengubah kisi menjadi satu kolom penuh.

Bayangan tampilan:

text
╭───────────────────────────────────╮
│ 9:41                      ▂▄▆ 🔋   │
│                                     │
│  ┌────┐ ┌────┐ ┌────┐             │
│  │▓▓▓▓│ │▓▓▓▓│ │▓▓▓▓│  ← 3 kolom  │
│  └────┘ └────┘ └────┘   di layar  │
│  ┌────┐ ┌────┐ ┌────┐    HP       │
│  │▓▓▓▓│ │▓▓▓▓│ │▓▓▓▓│             │
│  └────┘ └────┘ └────┘             │
│              ▬▬▬▬                  │
╰───────────────────────────────────╯

Di layar tablet yang lebih lebar, kisi yang sama otomatis jadi 5-6 kolom — tanpa kamu menulis angka kolom yang berbeda untuk tiap ukuran layar.

Kriteria selesai:

  • Jumlah kolom ditentukan oleh lebar maksimum tiap sel, bukan oleh angka kolom yang ditulis mati.
  • Gambar tetap persegi di lebar layar berapa pun, tanpa terdistorsi.
  • Berpindah antara kisi dan satu kolom mempertahankan posisi gulir yang masuk akal.
  • Kisi memakai GridView.builder, bukan membangun seluruh sel sekaligus.

Petunjuk: Delegasi grid berbasis lebar maksimum per sel beradaptasi sendiri terhadap lebar layar — ia menghitung berapa banyak sel yang muat, sehingga kamu tidak perlu menulis titik henti sama sekali. Ini hampir selalu pilihan yang lebih baik daripada jumlah kolom tetap, dan bedanya paling terasa di tablet. Untuk gambar yang tidak terdistorsi, fit pada widget gambar menentukan apa yang dikorbankan ketika rasio gambar tidak sama dengan rasio selnya.

Variasi 3: Daftar Kontak Berkelompok — ⭐⭐⭐ · 45–60 menit

Tantangan: Bangun daftar kontak yang dikelompokkan menurut huruf pertama nama, dengan judul kelompok yang menempel di atas saat digulir. Tambahkan bilah abjad di sisi kanan yang bisa disentuh untuk melompat ke huruf tertentu.

Bayangan tampilan:

text
╭───────────────────────────────────╮
│ 9:41                      ▂▄▆ 🔋   │
│ ══ A ═══════════════════════   A  │
│  Ade Bramantyo                 B  │
│  Andi Saputra                  C  │
│ ══ B ═══════════════════════   D  │
│  Budi Santoso                  E  │
│                                 F  │
│              ▬▬▬▬                  │
╰───────────────────────────────────╯

Bilah abjad di kanan (A B C D E F): huruf yang tidak punya kontak (mis. E, F) tampil redup dan tidak bisa disentuh. Tap C → daftar langsung menggulir ke grup "C".

Kriteria selesai:

  • Pengelompokan dihitung dari daftar kontak, bukan ditulis manual.
  • Judul kelompok menempel di atas selama kelompoknya masih terlihat.
  • Menyentuh huruf di bilah abjad menggulirkan daftar ke kelompok itu.
  • Huruf yang tidak punya kontak ditampilkan redup dan tidak bisa disentuh.

Petunjuk: Judul yang menempel adalah pekerjaan CustomScrollView dengan SliverList dan SliverPersistentHeader, atau CustomScrollView dengan beberapa SliverStickyHeader kalau kamu memakai pustaka. Untuk melompat ke posisi tertentu, ScrollController punya metode yang menerima posisi dalam piksel — jadi kamu perlu tahu tinggi tiap baris, yang menjadi jauh lebih mudah kalau seluruh barisnya bertinggi sama.

Variasi 4: Umpan dengan Muat Bertahap — ⭐⭐⭐ · 45–60 menit

Tantangan: Bangun umpan konten yang memuat dua puluh item pertama, lalu memuat dua puluh berikutnya secara otomatis ketika pengguna mendekati bagian bawah. Tambahkan tarik-untuk-menyegarkan yang mengganti seluruh isi dengan data baru.

Bayangan tampilan:

text
╭───────────────────────────────────╮
│ 9:41                      ▂▄▆ 🔋   │
│  ↓ tarik ke bawah untuk refresh    │
│  ┌─────────────────────────────┐  │
│  │ Post #1                      │  │
│  │ Post #2                      │  │
│  │ ...                          │  │
│  │ Post #20                     │  │
│  │      ⏳ Memuat lagi...        │  │
│  └─────────────────────────────┘  │
│              ▬▬▬▬                  │
╰───────────────────────────────────╯

Setelah data habis, ⏳ Memuat lagi... di bawah berubah jadi — Sudah semua —, bukan berputar selamanya.

Kriteria selesai:

  • Pemuatan berikutnya terpicu sekali per batas, bukan berkali-kali saat pengguna menggulir maju-mundur di dekat ujung.
  • Indikator memuat muncul di bagian bawah daftar, bukan menutupi seluruh layar.
  • Ketika data sudah habis, indikator diganti keterangan "sudah semua".
  • Tarik-untuk-menyegarkan tetap berfungsi meski daftarnya sedang kosong.

Petunjuk: Pemuatan yang terpicu berkali-kali adalah masalah nomor satu di pola ini: ScrollController memberi tahu posisi di setiap frame gulir, jadi tanpa penanda "sedang memuat" kamu akan mengirim lima permintaan sekaligus. Untuk daftar kosong, RefreshIndicator butuh anak yang benar-benar bisa digulir — daftar kosong tidak bisa digulir kecuali kamu memaksanya lewat physics yang selalu mengizinkan gulir.

Variasi 5: Papan Kanban — ⭐⭐⭐⭐ · 60–90 menit

Tantangan: Bangun papan kanban: beberapa kolom yang bergulir horizontal, masing-masing berisi daftar kartu yang bergulir vertikal sendiri-sendiri. Kartu bisa dipindahkan antar kolom lewat menu, dan jumlah kartu per kolom terlihat di judulnya.

Bayangan tampilan:

text
╭───────────────────────────────────╮
│ 9:41                      ▂▄▆ 🔋   │
│ ┌To Do(3)─┐┌Progres(2)┐┌─Done(1)─┐│
│ │ Kartu A ││ Kartu D  ││ Kartu F ││
│ │ Kartu B ││ Kartu E  ││         ││
│ │ Kartu C ││          ││         ││
│ └─────────┘└──────────┘└─────────┘│
│  ←── gulir horizontal antar kolom ──→│
│              ▬▬▬▬                  │
╰───────────────────────────────────╯

Pindahkan "Kartu A" dari To Do ke Progres → judul berubah seketika jadi To Do(2) dan Progres(3), tanpa mengganggu gulir vertikal di kolom lain.

Kriteria selesai:

  • Gulir horizontal antar kolom dan gulir vertikal di dalam kolom tidak saling mengganggu.
  • Setiap kolom memakai ListView.builder sendiri dengan lebar yang tetap.
  • Memindahkan kartu memperbarui kedua kolom seketika, dan jumlah di judul ikut berubah.
  • Kolom yang kosong tetap menampilkan area jatuh yang jelas, bukan kolaps.

Petunjuk: Daftar bergulir di dalam daftar bergulir dengan arah berbeda sebenarnya tidak konflik — yang konflik adalah constraint. Kolom di dalam ListView horizontal tidak punya lebar yang jelas, jadi berikan lebar tetap lewat SizedBox, dan ListView vertikal di dalamnya akan mendapat tinggi dari induknya. Hindari shrinkWrap di sini; ia akan membangun seluruh kartu di semua kolom sekaligus.

Variasi 6: Katalog Besar — ⭐⭐⭐⭐⭐ · 90–120 menit

Tantangan: Bangun katalog berisi lima ribu item dengan pencarian, penyaring kategori berganda, dan tiga pilihan pengurutan yang bisa dikombinasikan. Semua harus tetap terasa mulus — mengetik di kolom pencarian tidak boleh membuat antarmuka tersendat.

Bayangan tampilan:

text
╭───────────────────────────────────╮
│ 9:41                      ▂▄▆ 🔋   │
│  🔍 [sepatu...] [Kategori▾][Urut▾] │
│  ┌─────────────────────────────┐  │
│  │ Item 1 dari 5.000            │  │
│  │ Item 2                       │  │
│  │ ...                          │  │
│  └─────────────────────────────┘  │
│              ▬▬▬▬                  │
╰───────────────────────────────────╯

Mengetik "s-e-p-a-t-u" cepat → penyaringan hanya jalan sekali setelah kamu berhenti mengetik sejenak, bukan enam kali (satu per huruf).

Kriteria selesai:

  • Menggulir dari atas ke bawah tetap mulus, dan kamu sudah memeriksanya di mode profile, bukan mode debug.
  • Pencarian tidak menjalankan penyaringan pada setiap huruf yang diketik.
  • Kombinasi penyaring dan pengurutan menghasilkan hasil yang benar dalam urutan penerapan apa pun.
  • Daftar hasil kosong punya tampilannya sendiri yang menjelaskan penyaring mana yang membuatnya kosong.

Petunjuk: Menyaring lima ribu item pada setiap ketukan tombol adalah pekerjaan yang berat dilakukan enam puluh kali per detik. Tunda pemrosesan sampai pengguna berhenti mengetik sejenak — pola yang sama akan muncul lagi sebagai debounce di Bagian III. Dan untuk mengukur kemulusan, ingat aturan yang ditegaskan di Bab 13: jangan pernah mengukur performa di mode debug, karena angkanya tidak berarti apa-apa.

Ikhtisar

  • Column tidak bisa digulir dan membangun semua anaknya — jangan pakai untuk daftar.
  • ListView dengan children bisa digulir tetapi tetap membangun semuanya. Pakai hanya untuk daftar pendek yang tetap.
  • ListView.builder hanya membangun item yang terlihat. Ini yang seharusnya kamu pakai untuk daftar dinamis.
  • Jangan pakai shrinkWrap: true untuk mengatasi error tinggi tak terbatas di dalam Column — pakai Expanded, karena shrinkWrap membatalkan seluruh keuntungan builder.
  • ListView.separated menyisipkan pemisah antar item, tanpa pemisah di akhir.
  • GridView.builder dengan SliverGridDelegateWithMaxCrossAxisExtent beradaptasi otomatis terhadap lebar layar — lebih baik daripada jumlah kolom tetap.
  • Dismissible wajib punya key unik berbasis ID data, bukan indeks. Pasangkan dengan snackbar "Urungkan" dan insert(indeksLama, item).
  • RefreshIndicator butuh anak yang bisa digulir; untuk daftar kosong pakai AlwaysScrollableScrollPhysics.
  • Saat menyaring dan mengurutkan, salin listnya dulu dengan List.from()sort() mengubah list aslinya.
  • Hitung daftar yang ditampilkan lewat getter, bukan variabel yang harus disinkronkan manual.

Berikutnya: Bab 9 — Theming, Styling & Aset, agar aplikasimu tidak lagi memakai warna bawaan.

Transkrip asli

Disintesis dari 1_flutter_core/4_expense-tracker_interactive-widget-theming.md (video 6–8, 25–26), 7_meals-app_multi-screen-n-navigating-app.md (video 3–8), dan 1_flutter_zero-to-hero/2_basic-layout.md. Lihat PDF Flutter Core.

Rangkuman pembelajaran pribadi, disusun ulang dari beberapa kursus Flutter.