Stuck Saat Scroll di ListView.builder? Ini Penyebab & Solusinya

Saat mengembangkan sebuah aplikasi Flutter, saya menemukan bug aneh: aplikasi tersendat ketika saya scroll kembali ke atas pada layar yang panjang. Awalnya saya kira hanya glitch rendering biasa, tapi setelah saya telusuri lebih jauh, ternyata ada penyebab spesifiknya.

Layar tersebut menggunakan ListView.builder, di mana setiap item child bisa berbeda-beda. Ada yang hanya teks biasa, ada yang berupa button, ada yang berupa form, dan ada juga widget kompleks dengan controller serta animasi.  Widget kompleks inilah yang ternyata menjadi penyebab stuck saat scroll. Dari proses debugging, satu item child kompleks itu ter-dispose ketika keluar layar, lalu di-init ulang saat muncul kembali sehingga mengganggu perilaku ketika scroll.

APLIKASI ABSENSI ONLINE

Inilah pelajaran yang saya peroleh dari pengalaman mengatasi isu stuck saat scroll di ListView.builder dan ingin saya bagikan kepada Anda.

Langkah reproduksi

  1. Buat ListView.builder panjang (agar virtualization/recycling terjadi).
  2. Salah satu child item adalah StatefulWidget kompleks yang mengelola AnimationController, ScrollController, pemutar media, atau timer.
  3. Scroll sampai item tersebut ter-recycle (hilang dari layar).
  4. Scroll kembali ke atas dan perhatikan: list melompat, macet, atau tidak bisa mencapai atas dengan mulus.

Jika Anda tambahkan print() di initState / dispose pada child item yang kompleks tersebut, Anda akan melihat dispose dijalankan saat item hilang dari layar.

Penyebab Stuck Saat Scroll di ListView.builder

ListView.builder (melalui SliverChildBuilderDelegate) melakukan virtualisasi: item child yang off-screen bisa di-dispose untuk menghemat memori. Jika sebuah child menyimpan state yang penting (controller, subscription, posisi scroll internal), maka dispose & recreate saat user sedang scrolling bisa menyebabkan glitch layout / scroll.

Secara default SliverChildBuilderDelegate mendukung automatic keep-alives (addAutomaticKeepAlives bernilai true), tapi itu hanya berfungsi jika child mengimplementasikan kontrak keep-alive. Di sinilah AutomaticKeepAliveClientMixin diperlukan.

Solusi — pertahankan state child

Untuk mengatasi masalah stuck saat scroll di ListView.builder, berikut cara yang bisa dilakukan. Perlu diingat bahwa dalam banyak aplikasi nyata, tidak ada alasan untuk mempertahankan semua child dalam ListViewListView.builder memang dirancang untuk me-recycle dan dispose children yang tidak aktif saat off-screen. Kasus spesifik yang sering ditemui adalah:

  • Sebagian besar children adalah widget sederhana (text, row sederhana, button) yang murah untuk dibuat ulang — biarkan saja di-dispose.
  • Salah satu child adalah StatefulWidget kompleks (media player, view dengan animasi berat, timer aktif, atau sub-scrollable) yang harus mempertahankan State ketika user scroll. Jika tidak, membuat ulang widget tersebut selama scroll aktif akan menyebabkan layout meloncat atau stuck ketika scroll.

Dalam situasi ini, pendekatan yang benar adalah:

  1. Tetap gunakan ListView.builder (karena efisien dan sesuai).
  2. Terapkan AutomaticKeepAliveClientMixin hanya pada State child kompleks agar hanya widget tersebut yang dipertahankan.
  3. Pastikan ListView masih menggunakan automatic keep-alives (default addAutomaticKeepAlives: true), dan child kompleks memanggil super.build(context) serta mengembalikan true pada wantKeepAlive.

Berikut contoh main.dart minimal dan lengkap yang mendemonstrasikan pola ini. Jalankan dan lihat pada console: SimpleItem akan di-dispose saat keluar layar, sedangkan ComplexItem tidak. Sehingga mencegah perilaku snap/scroll tersendat.

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: 'ListView keepAlive example',
      home: const Scaffold(
        appBar: AppBar(title: Text('Mixed children: keep one alive')),
        body: ExampleList(),
      ),
    );
  }
}

class ExampleList extends StatelessWidget {
  const ExampleList({super.key});

  @override
  Widget build(BuildContext context) {
     // A long list where index 5 is the "complex" item that needs keep-alive.
    return ListView.builder(
      itemCount: 100,
      itemBuilder: (context, index) {
        if (index == 5) {
          // Give it a stable key so Flutter can track identity
          return ComplexItem(key: const ValueKey('complex-item'));
        }
        return SimpleItem(index: index);
      },
    );
  }
}

class SimpleItem extends StatefulWidget {
  final int index;
  const SimpleItem({super.key, required this.index});

  @override
  State<SimpleItem> createState() => _SimpleItemState();
}

class _SimpleItemState extends State<SimpleItem> {
  @override
  void initState() {
    super.initState();
    // Notice this will be called every time the item is created
    // which happens frequently for off-screen recycled items.
    // Helpful for debugging.
    // ignore: avoid_print
    print('SimpleItem.init ${widget.index}');
  }

  @override
  void dispose() {
    // ignore: avoid_print
    print('SimpleItem.dispose ${widget.index}');
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    // A cheap row that is fine to recreate
    return Container(
      height: 100,
      alignment: Alignment.centerLeft,
      padding: const EdgeInsets.symmetric(horizontal: 16),
      child: Text('Simple item #${widget.index}'),
    );
  }
}

// This is the complex item that we WANT to keep alive.
class ComplexItem extends StatefulWidget {
  const ComplexItem({super.key});

  @override
  State<ComplexItem> createState() => _ComplexItemState();
}

class _ComplexItemState extends State<ComplexItem>
    with AutomaticKeepAliveClientMixin<ComplexItem>, SingleTickerProviderStateMixin {
  late final AnimationController _controller;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      vsync: this,
      duration: const Duration(seconds: 2),
    )..repeat(reverse: true);
    // ignore: avoid_print
    print('ComplexItem.init');
  }

  @override
  void dispose() {
    _controller.dispose();
    // ignore: avoid_print
    print('ComplexItem.dispose');
    super.dispose();
  }

  // REPORT that we want to be kept alive when off-screen.
  @override
  bool get wantKeepAlive => true;

  @override
  Widget build(BuildContext context) {
    // IMPORTANT: call super.build when using AutomaticKeepAliveClientMixin
    super.build(context);

    return Container(
      height: 220,
      padding: const EdgeInsets.all(16),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          const Text('Complex item (kept alive)', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
          const SizedBox(height: 12),
          // An animated box to simulate heavy/expensive widget
          AnimatedBuilder(
            animation: _controller,
            builder: (context, child) {
              final size = 40.0 + 40.0 * _controller.value;
              return Container(width: size, height: size, color: Colors.blueAccent);
            },
          ),
          const SizedBox(height: 12),
          const Text('This widget holds an active AnimationController and should not be disposed while the user is scrolling.'),
        ],
      ),
    );
  }
}

Detail implementasi penting & jebakan umum

  • Panggil super.build(context) di dalam build() saat menggunakan AutomaticKeepAliveClientMixin. Jika lupa, maka proses keep-alive bisa gagal diam-diam (silent fail).
  • Jika widget Anda membutuhkan TickerProvider, kombinasikan dengan TickerProviderStateMixin atau SingleTickerProviderStateMixin.
  • Periksa flag pada SliverChildBuilderDelegateaddAutomaticKeepAlives bernilai true secara default. Biasanya Anda tidak perlu mengubah ini. Jika ada yang membuat ListView dengan addAutomaticKeepAlives: false, maka mixin tidak akan berpengaruh.

Tips tambahan untuk pola ini

  • Gunakan stable keys untuk item kompleks (ValueKey(id)), terutama jika list Anda bisa berubah urutan atau item memiliki identitas.
  • Conditional keep-alive: jika Anda hanya ingin mempertahankan item kompleks dalam kondisi tertentu (misalnya hanya saat memutar media), Anda bisa menambahkan boolean keepAlive di widget dan membuat wantKeepAlivemengembalikan nilai tersebut. Kemudian panggil updateKeepAlive() ketika nilai berubah (lihat contoh di bawah).
@override
bool get wantKeepAlive => _shouldKeepAlive;

// Panggil fungsi ini ketika kondisi berubah (misalnya video mulai/berhenti)
void _setShouldKeepAlive(bool value) {
  if (value != _shouldKeepAlive) {
    _shouldKeepAlive = value;
    updateKeepAlive();
  }
}
  • Jika Anda memiliki banyak item kompleks yang semuanya perlu dipertahankan, pertimbangkan untuk memindahkan state yang berat (misalnya player, controller) ke dalam manager (seperti Provider, Riverpod, atau caching service) agar tidak harus menjaga ratusan widget tetap hidup.

Pertimbangan performa

Mempertahankan banyak child kompleks bisa meningkatkan penggunaan memori dan CPU. Jika Anda memiliki ratusan item, menjaga terlalu banyak item tetap hidup tidaklah realistis. Gunakan keep-alive hanya untuk item yang:

  • Mahal untuk diinisialisasi ulang, dan
  • Jumlah totalnya terbatas (atau bisa dijaga secara kondisional), atau
  • UX mengharuskan item tersebut tidak reset saat user melakukan scroll.

Jika Anda perlu mempertahankan banyak state item tetapi ingin membatasi penggunaan memori, pertimbangkan untuk memindahkan state ke cache atau manager dan menggunakannya kembali di seluruh item list.

Masalah stuck saat scroll di ListView.builder mungkin terdengar sepele, tetapi dapat berdampak besar terhadap pengalaman pengguna jika tidak ditangani dengan tepat. Semoga pengalaman yang saya bagikan ini bisa menjadi referensi bermanfaat bagi Anda yang sedang menghadapi tantangan serupa.

Scroll to top