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.
Inilah pelajaran yang saya peroleh dari pengalaman mengatasi isu stuck saat scroll di ListView.builder dan ingin saya bagikan kepada Anda.
Table of Contents
Langkah reproduksi
- Buat ListView.builder panjang (agar virtualization/recycling terjadi).
- Salah satu child item adalah StatefulWidget kompleks yang mengelola AnimationController, ScrollController, pemutar media, atau timer.
- Scroll sampai item tersebut ter-recycle (hilang dari layar).
- 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 ListView. ListView.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
StatefulWidgetkompleks (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:
- Tetap gunakan
ListView.builder(karena efisien dan sesuai). - Terapkan
AutomaticKeepAliveClientMixinhanya padaStatechild kompleks agar hanya widget tersebut yang dipertahankan. - Pastikan
ListViewmasih menggunakan automatic keep-alives (defaultaddAutomaticKeepAlives: true), dan child kompleks memanggilsuper.build(context)serta mengembalikantruepadawantKeepAlive.
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 dalambuild()saat menggunakanAutomaticKeepAliveClientMixin. Jika lupa, maka proses keep-alive bisa gagal diam-diam (silent fail). - Jika widget Anda membutuhkan
TickerProvider, kombinasikan denganTickerProviderStateMixinatauSingleTickerProviderStateMixin. - Periksa flag pada
SliverChildBuilderDelegate:addAutomaticKeepAlivesbernilai true secara default. Biasanya Anda tidak perlu mengubah ini. Jika ada yang membuatListViewdenganaddAutomaticKeepAlives: 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
keepAlivedi widget dan membuatwantKeepAlivemengembalikan nilai tersebut. Kemudian panggilupdateKeepAlive()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.

