ข้ามไปยังเนื้อหา

Slivers & Scrolling

scrollable list อาจมีหมื่น item แต่หน้าจอแสดงแค่ยี่สิบ การ build ทั้งหมื่นจะเป็นหายนะ แนวคิดหลักของ Flutter scrolling คือ lazy building: build เฉพาะ item ที่อยู่ใกล้พื้นที่ที่มองเห็นได้เท่านั้น และถูก recycle เมื่อคุณเลื่อน

นี่คือเหตุผลที่ constructor ที่คุณเลือกสำคัญมาก:

// ❌ Builds ALL children up front — fine for a few, fatal for thousands.
ListView(children: [ for (final item in items) Tile(item) ])
// ✅ Builds children on demand as they scroll into view.
ListView.builder(
itemCount: items.length,
itemBuilder: (context, i) => Tile(items[i]),
)

พิสูจน์แล้ว: ListView.builder build เฉพาะสิ่งที่อยู่ใกล้ viewport จริง ๆ

หัวข้อที่มีชื่อว่า “พิสูจน์แล้ว: ListView.builder build เฉพาะสิ่งที่อยู่ใกล้ viewport จริง ๆ”

ไม่ใช่แค่ claim ที่ต้องเชื่อเฉย ๆ — build counter พิสูจน์ตรง ๆ viewport สูง 300 ที่มี item สูง 50 ควรต้องการประมาณ 6 ตัวที่มองเห็นได้บวก buffer เล็กน้อย จาก list ที่มี 1000:

lib/lazy_list.dart
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart' show ScrollCacheExtent;
int builtCount = 0;
final Set<int> builtIndices = {};
class LazyList extends StatelessWidget {
const LazyList({super.key, this.scrollCacheExtent});
// ScrollCacheExtent (sealed: .pixels(n) / .viewport(multiplier)) — the
// current API. The older `cacheExtent: double` parameter is deprecated,
// see the callout below.
final ScrollCacheExtent? scrollCacheExtent;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 300,
child: ListView.builder(
scrollCacheExtent: scrollCacheExtent,
itemCount: 1000,
itemExtent: 50,
itemBuilder: (context, i) {
builtCount++;
builtIndices.add(i);
return SizedBox(height: 50, child: Text('item $i'));
},
),
);
}
}
test/lazy_list_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/lazy_list.dart';
void main() {
testWidgets('a 1000-item ListView.builder only builds items near a 300px viewport', (tester) async {
builtCount = 0;
builtIndices.clear();
await tester.pumpWidget(const MaterialApp(home: Scaffold(body: LazyList())));
expect(builtCount, lessThan(20), reason: 'nowhere near all 1000 items were built');
expect(builtIndices.contains(0), isTrue, reason: 'the first visible item was built');
expect(builtIndices.contains(999), isFalse, reason: 'the far-off-screen last item was never built');
});
}
00:00 +0: a 1000-item ListView.builder only builds items near a 300px viewport
00:00 +1: All tests passed!

(ตัดจากผล flutter test test/lessons/layout-and-rendering__slivers-and-scrolling/ จริงบนเครื่องนี้)

ตัวเลขจริงที่วัดได้ใน run นี้คือ 11 จาก 1000 — 6 item ที่พอดีกับ viewport 300px ที่ 50px ต่อตัวจริง บวก buffer default ของ Flutter ที่ถูก build ไว้นอกขอบที่มองเห็นเล็กน้อย (เพื่อไม่ให้เลื่อนนิดเดียวแล้วเห็นเฟรมว่างระหว่างรอ item ใหม่ build) builtIndices ไม่เคยมี 999 เลย — ปลายสุดของ list ไม่เคยถูกแตะจริง ๆ GridView.builder ให้การรับประกัน laziness เดียวกันสำหรับ grid

พิสูจน์แล้ว: scrollCacheExtent ควบคุมว่า Flutter pre-build ไปข้างหน้าไกลแค่ไหนโดยตรง

หัวข้อที่มีชื่อว่า “พิสูจน์แล้ว: scrollCacheExtent ควบคุมว่า Flutter pre-build ไปข้างหน้าไกลแค่ไหนโดยตรง”

cache คือ buffer zone (เป็น logical pixel หรือตัวคูณเทียบกับ viewport) ที่ build ไว้นอก viewport ที่มองเห็นได้ทั้งสองด้าน — พิสูจน์ได้ด้วยการให้ค่าที่ชัดเจนใหญ่กว่ามากแล้วดู build count ที่เพิ่มขึ้นตามกัน:

test/lazy_list_cache_extent_test.dart
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart' show ScrollCacheExtent;
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/lazy_list.dart';
void main() {
testWidgets('a larger scrollCacheExtent builds proportionally more items ahead of the viewport', (tester) async {
builtCount = 0;
builtIndices.clear();
await tester.pumpWidget(const MaterialApp(home: Scaffold(body: LazyList())));
final defaultCount = builtCount;
builtCount = 0;
builtIndices.clear();
await tester.pumpWidget(
const MaterialApp(home: Scaffold(body: LazyList(scrollCacheExtent: ScrollCacheExtent.pixels(1000)))),
);
final largeCount = builtCount;
expect(largeCount, greaterThan(defaultCount), reason: 'a 1000px cache buffer builds more items than the default');
expect(largeCount, lessThan(200), reason: 'still nowhere near the full 1000-item list — this is "how far ahead", not "build everything"');
});
}
00:00 +0: a larger scrollCacheExtent builds proportionally more items ahead of the viewport
00:00 +1: All tests passed!

(ตัดจากผล flutter test test/lessons/layout-and-rendering__slivers-and-scrolling/ จริงบนเครื่องนี้)

วัดตรง ๆ ใน run นี้: cache แบบ default build 11 item; ScrollCacheExtent.pixels(1000) build 26 (300px ที่มองเห็นได้ + 1000px cache = 1300px ของ item ขนาด 50px = 26) cache extent ที่ใหญ่กว่าหมายถึงการเลื่อนเร็ว ๆ ที่นุ่มนวลกว่า (พร้อมไว้ล่วงหน้ามากกว่า) แลกกับ memory และ build work ล่วงหน้าที่มากขึ้น — ปรับแต่งมัน อย่าเดาเอา

ใต้ scrollable คือ viewport — หน้าต่างที่คุณเห็น — และข้างในนั้นคือ sliver sliver คือพื้นที่ scrollable ที่รู้วิธี layout ตัวเองแบบ lazy เทียบกับ viewport ListView คือ convenience ที่ห่อ sliver ตัวเดียว; เมื่อต้องการ scroll effect มากกว่าหนึ่งใน scroll view เดียว คุณประกอบ sliver เอง

flowchart TB
  scroll["CustomScrollView"] --> vp["Viewport
(visible window)"]
  vp --> s1["SliverAppBar
(collapses on scroll)"]
  vp --> s2["SliverList
(builds only visible tiles)"]
  vp --> offscreen["off-screen tiles:
not built yet"]
viewport build เฉพาะ sliver ที่มองเห็นแบบ lazy

พิสูจน์แล้ว: CustomScrollView ประกอบ sliver type หลายแบบ — และแต่ละ SliverList/SliverGrid ยังคง lazy อิสระต่อกัน

หัวข้อที่มีชื่อว่า “พิสูจน์แล้ว: CustomScrollView ประกอบ sliver type หลายแบบ — และแต่ละ SliverList/SliverGrid ยังคง lazy อิสระต่อกัน”
lib/custom_scroll_demo.dart
import 'package:flutter/material.dart';
int sliverBuiltCount = 0;
class CustomScrollDemo extends StatelessWidget {
const CustomScrollDemo({super.key});
@override
Widget build(BuildContext context) {
return CustomScrollView(
slivers: [
const SliverAppBar(floating: true, expandedHeight: 100, title: Text('Demo')),
// SliverToBoxAdapter: drop a normal (non-sliver-aware) widget into a
// sliver list — a one-off section header, banner, etc.
const SliverToBoxAdapter(child: SizedBox(height: 40, child: Text('section header'))),
SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisExtent: 50,
),
delegate: SliverChildBuilderDelegate(
(context, i) => SizedBox(height: 50, child: Text('grid $i')),
childCount: 4,
),
),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, i) {
sliverBuiltCount++;
return SizedBox(height: 50, child: Text('row $i'));
},
childCount: 1000, // same laziness claim, in sliver form
),
),
],
);
}
}
test/custom_scroll_demo_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/custom_scroll_demo.dart';
void main() {
testWidgets('CustomScrollView composes SliverAppBar + SliverToBoxAdapter + SliverGrid + SliverList without error, staying lazy', (tester) async {
sliverBuiltCount = 0;
await tester.pumpWidget(const MaterialApp(home: Scaffold(body: CustomScrollDemo())));
expect(tester.takeException(), isNull);
expect(sliverBuiltCount, lessThan(30), reason: 'the 1000-item SliverList.builder-style delegate stays lazy inside a CustomScrollView too');
});
}
00:00 +0: CustomScrollView composes SliverAppBar + SliverToBoxAdapter + SliverGrid + SliverList without error, staying lazy
00:00 +1: All tests passed!

(ตัดจากผล flutter test test/lessons/layout-and-rendering__slivers-and-scrolling/ จริงบนเครื่องนี้)

วัดใน run นี้: 13 จาก 1000 แถวของ SliverList ถูก build — laziness เป็นคุณสมบัติของ sliver ไม่ใช่ของ ListView โดยเฉพาะ นั่นคือเหตุผลที่การประกอบ sliver หลายตัวเข้า CustomScrollView เดียวยัง scroll เป็นหน่วยเดียวได้ในขณะที่แต่ละ sliver จัดการช่วง lazy ของตัวเองอิสระ mental model: widget ธรรมดายึดกล่องคงที่; sliver ยึด scrollable extent และเจรจากับ viewport ว่ามองเห็นเท่าไร SliverAppBar, SliverList, SliverGrid, และ SliverToBoxAdapter (สำหรับใส่ widget ธรรมดาลงใน sliver list) คือชิ้นส่วนที่คุณประกอบ

พิสูจน์แล้ว: ScrollController + addPostFrameCallback — pattern จริงสำหรับ scroll หลัง layout แรก

หัวข้อที่มีชื่อว่า “พิสูจน์แล้ว: ScrollController + addPostFrameCallback — pattern จริงสำหรับ scroll หลัง layout แรก”

ScrollController สั่ง scroll ก่อนที่ scrollable ที่มันผูกอยู่จะ layout ตัวเองจริง ๆ ไม่ได้ — position ของมันยังไม่มีอยู่จนกว่าจะหลังเฟรมแรก addPostFrameCallback จาก initState คือวิธีมาตรฐานในการรัน scroll logic แค่ครั้งเดียว ทันทีหลัง layout แรกนั้นเสร็จ:

lib/scroll_controller_demo.dart
import 'package:flutter/material.dart';
class JumpToEndList extends StatefulWidget {
const JumpToEndList({super.key});
@override
JumpToEndListState createState() => JumpToEndListState();
}
class JumpToEndListState extends State<JumpToEndList> {
final controller = ScrollController();
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
controller.jumpTo(controller.position.maxScrollExtent);
});
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SizedBox(
height: 300,
child: ListView.builder(
controller: controller,
itemCount: 50,
itemExtent: 50,
itemBuilder: (context, i) => SizedBox(height: 50, child: Text('item $i')),
),
);
}
}
test/scroll_controller_demo_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/scroll_controller_demo.dart';
void main() {
testWidgets('addPostFrameCallback jumps to the bottom right after first layout', (tester) async {
final key = GlobalKey<JumpToEndListState>();
await tester.pumpWidget(MaterialApp(home: Scaffold(body: JumpToEndList(key: key))));
await tester.pump(); // let the scheduled post-frame callback actually run
final controller = key.currentState!.controller;
expect(controller.offset, controller.position.maxScrollExtent);
expect(controller.offset, 2200.0, reason: '50 items * 50px = 2500 content height, minus the 300px viewport');
});
}
00:00 +0: addPostFrameCallback jumps to the bottom right after first layout
00:00 +1: All tests passed!

(ตัดจากผล flutter test test/lessons/layout-and-rendering__slivers-and-scrolling/ จริงบนเครื่องนี้)

WidgetsBinding.instance.addPostFrameCallback คือ hook อเนกประสงค์ “รันสิ่งนี้เมื่อเฟรมปัจจุบันเสร็จจริง ๆ แล้ว” — เครื่องมือเดียวกับที่ใช้ทุกครั้งที่โค้ดต้อง react กับ layout ที่เสร็จสมบูรณ์แล้ว (อ่าน size/position สุดท้าย, ขับเฟรมแรกของ animation หรือในที่นี้ jump controller ก่อนที่ user จะเห็นตำแหน่งก่อน jump เลย)

พิสูจน์แล้ว: AutomaticKeepAliveClientMixin รอดจากการเลื่อนออกนอกจอ; State ธรรมดาไม่รอด

หัวข้อที่มีชื่อว่า “พิสูจน์แล้ว: AutomaticKeepAliveClientMixin รอดจากการเลื่อนออกนอกจอ; State ธรรมดาไม่รอด”

โดย default State ของ list item จะถูก dispose ทันทีที่มันเลื่อนออกจากช่วงที่ build ไว้ (พิสูจน์แล้วข้างบน — นั่นคือสิ่งที่ทำให้ laziness ทำงาน) AutomaticKeepAliveClientMixin ให้ item เฉพาะตัว ไม่ต้องทำแบบนั้น: framework เก็บ Element/State ของมันไว้ใน bucket พิเศษแม้จะไม่ได้อยู่ในช่วงที่มองเห็นได้+cache แล้วเชื่อมกลับ ตัวเดิม เมื่อมันเลื่อนกลับเข้ามาในจอ

lib/keep_alive_demo.dart
import 'package:flutter/material.dart';
int nextKeepAliveId = 0;
int nextPlainId = 0;
/// Stamps an id once, in initState — proves whether the SAME State survives
/// a round trip off-screen and back, exactly like the State-persistence
/// proofs elsewhere in this course.
class KeepAliveItem extends StatefulWidget {
const KeepAliveItem({super.key});
@override
KeepAliveItemState createState() => KeepAliveItemState();
}
class KeepAliveItemState extends State<KeepAliveItem> with AutomaticKeepAliveClientMixin {
late final int id;
@override
void initState() {
super.initState();
id = nextKeepAliveId++;
}
@override
bool get wantKeepAlive => true;
@override
Widget build(BuildContext context) {
super.build(context); // required by AutomaticKeepAliveClientMixin
return SizedBox(height: 50, child: Text('keepalive $id'));
}
}
/// No mixin — the ordinary, default behavior.
class PlainItem extends StatefulWidget {
const PlainItem({super.key});
@override
PlainItemState createState() => PlainItemState();
}
class PlainItemState extends State<PlainItem> {
late final int id;
@override
void initState() {
super.initState();
id = nextPlainId++;
}
@override
Widget build(BuildContext context) => SizedBox(height: 50, child: Text('plain $id'));
}
class KeepAliveDemoList extends StatelessWidget {
const KeepAliveDemoList({super.key, required this.controller});
final ScrollController controller;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 300,
child: ListView.builder(
controller: controller,
itemCount: 200,
itemExtent: 50,
itemBuilder: (context, i) {
if (i == 0) return const KeepAliveItem();
if (i == 1) return const PlainItem();
return SizedBox(height: 50, child: Text('filler $i'));
},
),
);
}
}
test/keep_alive_demo_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/keep_alive_demo.dart';
void main() {
testWidgets('AutomaticKeepAliveClientMixin keeps its State after scrolling far away and back; a plain State does not', (tester) async {
nextKeepAliveId = 0;
nextPlainId = 0;
final controller = ScrollController();
await tester.pumpWidget(MaterialApp(home: Scaffold(body: KeepAliveDemoList(controller: controller))));
final firstKeepAliveId = tester.state<KeepAliveItemState>(find.byType(KeepAliveItem)).id;
final firstPlainId = tester.state<PlainItemState>(find.byType(PlainItem)).id;
expect(firstKeepAliveId, 0);
expect(firstPlainId, 0);
// Scroll far enough away that both items leave the built+cached range.
controller.jumpTo(8000);
await tester.pump();
expect(find.byType(KeepAliveItem), findsNothing, reason: 'scrolled far off-screen — not in the current built widget tree either way');
expect(find.byType(PlainItem), findsNothing);
// Scroll back to the top.
controller.jumpTo(0);
await tester.pump();
expect(
tester.state<KeepAliveItemState>(find.byType(KeepAliveItem)).id,
0,
reason: 'AutomaticKeepAliveClientMixin: the SAME State object survived the round trip',
);
expect(
tester.state<PlainItemState>(find.byType(PlainItem)).id,
1,
reason: 'plain State: a brand-new State was created on the way back — the old one was genuinely disposed',
);
});
}
00:00 +0: AutomaticKeepAliveClientMixin keeps its State after scrolling far away and back; a plain State does not
00:00 +1: All tests passed!

(ตัดจากผล flutter test test/lessons/layout-and-rendering__slivers-and-scrolling/ จริงบนเครื่องนี้)

ทั้งสอง item หายจาก find.byType เท่ากันตอนเลื่อนไปไกล 8000px — ทั้งคู่ไม่ได้อยู่ใน widget tree ที่ build ไว้ ณ จุดนั้น ความต่างปรากฏแค่ตอนเลื่อน กลับมา: id ของ item ที่มี keep-alive ยังเป็น 0 (State ตัวเดิมเป๊ะ เชื่อมกลับจากที่ที่ Flutter เก็บมันไว้) ในขณะที่ item ธรรมดามี id 1 — พิสูจน์ว่า State ใหม่ถูกสร้างขึ้นจริง ใช้สิ่งนี้เฉพาะกับ state ที่ rebuild แพงและต้องรอดจาก scroll churn (ตำแหน่งเล่นของ video player, form ที่ user กรอกค้างไว้ใน list item) — การให้ item หลายตัว keep alive ทำลายประโยชน์ทาง memory ของ laziness ที่บทเรียนทั้งบทนี้พูดถึง

NestedScrollView ประสานงาน scrollable ด้านนอก (โดยทั่วไปเป็น sliver อย่าง SliverAppBar) กับ scrollable ด้านใน (อย่าง list ต่อแท็บของ TabBarView) ให้แชร์ scroll gesture เดียวต่อเนื่องกัน — header ด้านนอกยุบตัวขณะเนื้อหาด้านในเลื่อน แทนที่สองอันจะแย่ง gesture กันแบบที่ nested scrollable อิสระสองตัวจะทำ (อันตรายจาก nesting เดียวกับ crash unbounded-height ของบทเรียน Constraints & Layout แต่เป็นการชนกันของ scroll gesture แทนที่จะเป็น layout constraint)

NestedScrollView(
headerSliverBuilder: (context, innerBoxIsScrolled) => [
const SliverAppBar(title: Text('Profile'), expandedHeight: 200, pinned: true),
],
body: TabBarView(children: [FeedTab(), PhotosTab()]),
)

Scrollbar ห่อ scrollable เพื่อวาดตัวชี้ scroll ทาง visual และบน desktop/web เพิ่มการ drag-to-scroll ให้ตัวชี้เองด้วย มันต้องการ controller ที่ส่งเข้ามาตรง ๆ (ตัวเดียวกับที่ scrollable ที่มันห่ออยู่ใช้) หรือ PrimaryScrollController ที่ผูกให้อัตโนมัติ (default สำหรับ scrollable ระดับบนสุดตัวเดียวต่อ route)

Scrollbar(
controller: myController,
thumbVisibility: true, // always show it, not just while scrolling
child: ListView(controller: myController, children: [...]),
)

ภายใน CustomScrollView SliverFillRemaining ยึดพื้นที่ที่เหลือใน viewport หลังจาก sliver อื่น ๆ — คือ Expanded เวอร์ชัน sliver ภายใน Column มีประโยชน์สำหรับข้อความ empty-state หรือปุ่ม submit ของ form ที่ควรอยู่ที่ด้านล่างของพื้นที่ที่มองเห็นได้ แทนที่จะอยู่ทันทีหลังเนื้อหาข้างบน

CustomScrollView(
slivers: [
const SliverAppBar(title: Text('Empty state demo')),
SliverFillRemaining(
hasScrollBody: false, // content does not itself need to scroll
child: const Center(child: Text('Nothing here yet')),
),
],
)
  • การ nest scrollable ทิศทางเดียวกัน (ListView ใน ListView) แย่ง scroll กันและ throw error unbounded-constraint — ใช้ sliver หรือ shrinkWrap โดยตั้งใจ (กลไกและข้อความ error จริงพิสูจน์ไว้ใน บทเรียน Constraints & Layout)
  • shrinkWrap: true ทำให้ list มีขนาดตามเนื้อหาแทนที่จะเป็น viewport แต่มัน build ทุกอย่าง และเสีย laziness — ใช้เฉพาะ list เล็ก ๆ จริง ๆ (พิสูจน์แล้วเช่นกัน พร้อมวิธีแก้จริงทั้งสามแบบ ใน บทเรียน Constraints & Layout)
  • key ที่หายไป บน list ที่ reorder ได้ ทำให้ state ผิดตัวไปติดกับ item ผิด (พิสูจน์ด้วย checkbox test จริงใน บทเรียน Keys)

scrollable คือ projector ที่ฉาย filmstrip ผ่านหน้าต่างเล็ก ๆ: หน้าต่าง (viewport) ต้องการแค่ไม่กี่เฟรม (item) ที่เรียงอยู่หลังมันตอนนี้ บวก buffer สั้น ๆ แต่ละด้านเพื่อไม่ให้การหมุนม้วนฟิล์มแสดงแฟลชว่าง (scrollCacheExtent) — การ build filmstrip ทั้งหมดล่วงหน้าอย่างที่ ListView(children: [...]) ทำ คือการพิมพ์ทุกเฟรมของหนังสามชั่วโมงก่อนจะฉายเฟรมแรก AutomaticKeepAliveClientMixin คือการเอาเฟรมเฉพาะตัวใส่ pocket พิเศษแทนที่จะปล่อยให้มันกลับเข้าตลับฟิล์มเมื่อมันผ่านออกจากสายตา — แพงถ้าทำกับทุกเฟรม แต่จำเป็นสำหรับเฟรมเดียว (แถวที่กำลังเล่นอยู่ของ music player เช่น) ที่ต้องจำอะไรบางอย่างข้ามการหมุนม้วนฟิล์มจริง ๆ

ทางเลือกข้อดีต้นทุน
ListView.builder/GridView.builderLazy — พิสูจน์แล้วข้างบนว่า build แค่เศษเสี้ยวของ list ใหญ่item widget ต้องสร้างได้อิสระ (ไม่พึ่ง sibling ที่มีอยู่แล้ว)
scrollCacheExtent ที่ใหญ่กว่าเลื่อนเร็ว ๆ นุ่มนวลกว่า — พร้อมไว้ล่วงหน้ามากกว่า พิสูจน์แล้วข้างบนmemory และ build work ล่วงหน้าต่อเฟรมมากขึ้น
CustomScrollView + sliverscroll gesture ต่อเนื่องเดียวข้ามเนื้อหาหลากหลาย (header + grid + list)ceremony มากกว่า ListView เดียว; คุ้มเฉพาะเมื่อต้องการ scroll effect มากกว่าหนึ่งจริง ๆ
AutomaticKeepAliveClientMixinstate ของ item เฉพาะตัวรอดจากการเลื่อนออกจริง — พิสูจน์แล้วข้างบนทำลายประโยชน์ทาง memory ของ laziness สำหรับ item นั้น; ใช้มากเกินไปกับหลาย item สร้างปัญหา “build ทุกอย่าง” ที่บทเรียนทั้งบทนี้มีไว้เพื่อหลีกเลี่ยง
shrinkWrap: trueทำให้ list มีขนาดตามเนื้อหาbuild ทุก item ล่วงหน้า — ตรงข้ามกับ laziness ที่พิสูจน์แล้วข้างบน; เฉพาะ list เล็กที่มี bound เท่านั้น
  • “ListView() กับ ListView.builder() ทำงานเหมือนกัน builder แค่เป็นทางเลือกด้าน style” พิสูจน์แล้วข้างบน: .builder build เศษเสี้ยวของ list ที่วัดได้ (11 จาก 1000); ListView(children: [...]) ธรรมดาสร้างทุก item ทันที ไม่ว่า viewport จะขนาดเท่าไร
  • “cacheExtent/scrollCacheExtent มีผลแค่ความนุ่มนวลของการ scroll ไม่ใช่สิ่งที่ถูก build” พิสูจน์แล้วข้างบนด้วยตัวเลขที่วัดได้จริง: มันควบคุมโดยตรงว่ามี item กี่ตัวถูก build ล่วงหน้าจากช่วงที่มองเห็นได้ (11 → 26 item สำหรับ cache 1000px) — และหมายเหตุว่า cacheExtent เองถูก deprecate ตั้งแต่ 3.41.0 แล้วเปลี่ยนเป็น scrollCacheExtent (พิสูจน์แล้วข้างบนด้วย analyzer diagnostic จริง)
  • “ScrollController สั่งให้ jump ไปตำแหน่งได้ทันทีหลัง pumpWidget” pattern ที่พิสูจน์แล้วข้างบนต้องการ addPostFrameCallback โดยเฉพาะเพราะ controller.position ยังไม่มีอยู่จนกว่าจะหลัง layout แรก — เรียก jumpTo เร็วกว่านั้นไม่มีอะไรให้มันทำงานด้วยเลย
  • “AutomaticKeepAliveClientMixin เก็บ list item ทั้งหมดไว้อัตโนมัติทันทีที่เพิ่มมันที่ไหนก็ได้” พิสูจน์แล้วข้างบนว่าเป็นแบบ per-item และ opt-in: มีแค่ widget เฉพาะที่ State mix มันเข้าไป (และคืน wantKeepAlive => true) เท่านั้นที่รอดจาก round trip — PlainItem ที่เป็น sibling ใน list เดียวกันเป๊ะ ห่างแค่หนึ่ง index พิสูจน์แล้วว่าไม่รอด

โค้ด list ของ Flutter ที่ AI generate มักเลือก ListView(children: [for (...) Tile(...)]) แบบไม่ lazy สำหรับข้อมูลที่ความยาวไม่มี bound จริงหรือมาจาก database — เพราะ training data tutorial ส่วนใหญ่ใช้ form ง่าย ๆ กับ item จำนวนน้อยที่ hardcode ไว้ และความเคยชินนั้นติดตามไปในบริบทที่มันไม่ scale เงียบ ๆ

// @expect-error compiles clean — builds every single order up front, the opposite of the laziness proven above, once orders.length is large
class OrderHistoryScreen extends StatelessWidget {
const OrderHistoryScreen({super.key, required this.orders});
final List<Order> orders;
@override
Widget build(BuildContext context) {
return ListView(
children: [for (final order in orders) OrderTile(order: order)],
);
}
}

ก่อนเชื่อโค้ด scrolling ที่ generate มา ให้เช็ค:

  • ความยาวของ list มี bound และเล็ก (settings toggle ไม่กี่ตัว) หรือมีโอกาสโตใหญ่จริง (ผลค้นหา, order history, chat message)? มีแค่แบบหลังเท่านั้นที่ต้องการ .builder พิสูจน์แล้วข้างบนว่าสำคัญมาก
  • item ต้องจำอะไรบางอย่าง (state ภายใน scroll, ตำแหน่งเล่นของ video, การแก้ไขที่ค้างอยู่) ข้ามการถูกเลื่อนออกและกลับมาหรือเปล่า? ถ้าใช่ มันถูกห่อด้วย AutomaticKeepAliveClientMixin จริงหรือเปล่า พิสูจน์แล้วข้างบนว่าเป็นกลไกจริง — ไม่ใช่แค่หวังว่า “มันจะยังอยู่”?
  • ScrollController.jumpTo/animateTo ถูกเรียกตรงใน build() หรือ initState() โดยไม่มี addPostFrameCallback หรือเปล่า? นั่นคือบั๊กเรื่องลำดับเป๊ะที่พิสูจน์แล้วข้างบน (position ยังไม่มีอยู่)
  • scrollable สองตัวถูก nest ทิศทางเดียวกันโดยไม่มี shrinkWrap/sliver ที่ตั้งใจหรือเปล่า? นั่นคือ crash จริงที่พิสูจน์แล้วใน บทเรียน Constraints & Layout

🐛 หา bug ในโค้ดนี้

class ChatScreen extends StatefulWidget {
const ChatScreen({super.key});
@override
State<ChatScreen> createState() => _ChatScreenState();
}
class _ChatScreenState extends State<ChatScreen> {
final controller = ScrollController();
@override
void initState() {
super.initState();
controller.jumpTo(controller.position.maxScrollExtent);
}
@override
Widget build(BuildContext context) {
return ListView.builder(
controller: controller,
itemCount: 500,
itemBuilder: (context, i) => MessageBubble(index: i),
);
}
}
เฉลย

controller.jumpTo(...) ถูกเรียกตรงภายใน initState() ก่อนที่ ListView จะเคยถูก layout เลย — controller.position ยังไม่มีอยู่ ณ จุดนั้น (ยังไม่มี scrollable ผูกกับ controller นี้เลย) ดังนั้นมัน throw ทันทีแทนที่จะเลื่อนไปด้านล่าง วิธีแก้ พิสูจน์แล้วข้างบนด้วย test จริง: ห่อ jump ด้วย WidgetsBinding.instance.addPostFrameCallback((_) { ... }) เพื่อให้มันรันหลังเฟรมแรก build และ layout list เสร็จจริงแล้ว เมื่อ position มีอยู่จริง ๆ

ทำไมต้องใช้ ListView.builder สำหรับ list ยาว?
พิสูจน์ด้วย build counter จริงในบทนี้ ListView.builder สูง 300px build ประมาณกี่ item จาก 1000?
พิสูจน์แล้วข้างบนด้วยการเทียบ default กับ ScrollCacheExtent.pixels(1000) scroll cache extent ควบคุมอะไรจริง ๆ?
พิสูจน์ด้วย test จริงในบทนี้ ทำไมการเรียก controller.jumpTo() ตรงใน initState() ถึงล้มเหลว?
พิสูจน์แล้วข้างบนด้วย list item ที่เกือบเหมือนกันสองตัว (ตัวหนึ่งมี AutomaticKeepAliveClientMixin ตัวหนึ่งไม่มี) เกิดอะไรกับแต่ละตัวหลังเลื่อนไปไกลแล้วกลับมา?
CustomScrollView ให้คุณทำอะไรที่ ListView เดียวทำไม่ได้ ตามบทนี้?