Navigator & Routes
route stack
หัวข้อที่มีชื่อว่า “route stack”Navigator คือ widget ที่จัดการ stack ของ route โดย route หนึ่งคือหน้าจอหนึ่ง route ที่อยู่ top สุดของ stack คือสิ่งที่ user เห็น navigation ก็แค่ operation บน stack สองอย่าง: push route ใหม่ขึ้น top หรือ pop route บนสุดออกเพื่อเผยตัวที่อยู่ข้างใต้
flowchart TB
subgraph stack["route stack"]
top["DetailScreen (visible, top)"]
mid["ListScreen"]
bottom["HomeScreen (bottom)"]
end
top -->|pop| mid
mid -->|push DetailScreen| top ปุ่ม back ของเครื่อง, ลูกศร back บน app bar และ swipe-back gesture ทั้งหมดทำสิ่งเดียวกัน: pop route ที่อยู่ top
push, pop, pushReplacement, pushNamedAndRemoveUntil — พิสูจน์จริง
หัวข้อที่มีชื่อว่า “push, pop, pushReplacement, pushNamedAndRemoveUntil — พิสูจน์จริง”คุณเอา Navigator มาจาก BuildContext ปัจจุบันด้วย Navigator.of(context) แล้วเรียก operation บน stack ได้สี่แบบ push กับ pop เพิ่ม/ลด stack ทีละหนึ่ง; pushReplacement สลับ entry ที่อยู่ top ด้วยอันใหม่โดยไม่แตะความลึกด้านล่าง; pushNamedAndRemoveUntil push named route ใหม่แล้ว pop ทุกอันด้านล่างที่ไม่ผ่าน predicate — (route) => false เคลียร์ทั้ง stack เป็น pattern มาตรฐานของ “log out” / “onboarding เสร็จแล้ว”
import 'package:flutter/material.dart';
class HomeScreen extends StatelessWidget { const HomeScreen({super.key}); @override Widget build(BuildContext context) { return Scaffold( body: Center( child: TextButton( onPressed: () => Navigator.of(context).push( MaterialPageRoute<void>(builder: (context) => const ListScreen()), ), child: const Text('Home — go to List'), ), ), ); }}
class ListScreen extends StatelessWidget { const ListScreen({super.key}); @override Widget build(BuildContext context) { return Scaffold( body: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ const Text('List'), TextButton( onPressed: () async { // push<int> + await: the pushed screen's pop(value) resolves this Future. final picked = await Navigator.of(context).push<int>( MaterialPageRoute<int>(builder: (context) => const DetailScreen()), ); lastPoppedResult = picked; }, child: const Text('List — go to Detail'), ), TextButton( // pushReplacement: swaps List for Settings. Home, below List, is untouched. onPressed: () => Navigator.of(context).pushReplacement( MaterialPageRoute<void>(builder: (context) => const SettingsScreen()), ), child: const Text('List — replace with Settings'), ), ], ), ), ); }}
class DetailScreen extends StatelessWidget { const DetailScreen({super.key}); @override Widget build(BuildContext context) { return Scaffold( body: Center( child: TextButton( onPressed: () => Navigator.of(context).pop(42), child: const Text('Detail — pop with 42'), ), ), ); }}
class SettingsScreen extends StatelessWidget { const SettingsScreen({super.key}); @override Widget build(BuildContext context) => const Scaffold(body: Center(child: Text('Settings')));}
/// A plain top-level var, not app state — just a hook so the test below can/// read the value that `pop(42)` sent back through the awaited `push<int>`.int? lastPoppedResult;import 'package:flutter/material.dart';import 'package:flutter_test/flutter_test.dart';import 'package:my_app/stack_screens.dart';
void main() { testWidgets( 'push/pop moves the stack and returns a value; pushReplacement swaps the top without changing depth below it', (tester) async { lastPoppedResult = null; await tester.pumpWidget(const MaterialApp(home: HomeScreen())); expect(find.text('Home — go to List'), findsOneWidget);
await tester.tap(find.text('Home — go to List')); await tester.pumpAndSettle(); expect(find.text('List'), findsOneWidget);
await tester.tap(find.text('List — go to Detail')); await tester.pumpAndSettle(); expect(find.text('Detail — pop with 42'), findsOneWidget);
await tester.tap(find.text('Detail — pop with 42')); await tester.pumpAndSettle(); expect(find.text('List'), findsOneWidget, reason: 'pop reveals the screen below on the stack'); expect(lastPoppedResult, 42, reason: 'the value passed to pop() travels back through the awaited push<int>()');
await tester.tap(find.text('List — replace with Settings')); await tester.pumpAndSettle(); expect(find.text('Settings'), findsOneWidget); expect(find.text('List'), findsNothing, reason: 'pushReplacement removed List — it did not stay stacked underneath');
final navigator = tester.state<NavigatorState>(find.byType(Navigator).first); expect(navigator.canPop(), isTrue, reason: 'Home is still below Settings — pushReplacement only swapped the top entry');
navigator.pop(); await tester.pumpAndSettle(); expect(find.text('Home — go to List'), findsOneWidget, reason: 'popping Settings reveals Home directly — List never reappears, because it was replaced, not pushed over'); }, );}✓ push/pop moves the stack and returns a value; pushReplacement swaps the top without changing depth below it
00:00 +1: All tests passed!pushNamedAndRemoveUntil ต้องมี named route ถึงจะสาธิตได้ เลยแยกเป็นแอปเล็ก ๆ ของตัวเอง:
import 'package:flutter/material.dart';
class RemoveUntilApp extends StatelessWidget { const RemoveUntilApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( initialRoute: '/', routes: { '/': (context) => const NamedHome(), '/settings': (context) => const NamedSettings(), '/login': (context) => const NamedLogin(), }, ); }}
class NamedHome extends StatelessWidget { const NamedHome({super.key}); @override Widget build(BuildContext context) => Scaffold( body: TextButton( onPressed: () => Navigator.of(context).pushNamed('/settings'), child: const Text('Home'), ), );}
class NamedSettings extends StatelessWidget { const NamedSettings({super.key}); @override Widget build(BuildContext context) => Scaffold( body: TextButton( // (route) => false rejects every existing route, so ALL of them — // including Home — are removed, not just the ones "above" /login. onPressed: () => Navigator.of(context) .pushNamedAndRemoveUntil('/login', (route) => false), child: const Text('Settings — log out'), ), );}
class NamedLogin extends StatelessWidget { const NamedLogin({super.key}); @override Widget build(BuildContext context) => const Scaffold(body: Text('Login'));}import 'package:flutter/material.dart';import 'package:flutter_test/flutter_test.dart';import 'package:my_app/remove_until_app.dart';
void main() { testWidgets('pushNamedAndRemoveUntil((route) => false) clears the ENTIRE stack, not just routes above the target', (tester) async { await tester.pumpWidget(const RemoveUntilApp()); await tester.tap(find.text('Home')); await tester.pumpAndSettle(); expect(find.text('Settings — log out'), findsOneWidget);
await tester.tap(find.text('Settings — log out')); await tester.pumpAndSettle(); expect(find.text('Login'), findsOneWidget);
final navigator = tester.state<NavigatorState>(find.byType(Navigator).first); expect(navigator.canPop(), isFalse, reason: 'Home and Settings are both gone — Login is the only entry left on the stack'); });}✓ pushNamedAndRemoveUntil((route) => false) clears the ENTIRE stack, not just routes above the target
00:00 +1: All tests passed!PopScope และทำไม WillPopScope ถึงหายไป
หัวข้อที่มีชื่อว่า “PopScope และทำไม WillPopScope ถึงหายไป”การดัก back gesture แต่ก่อนใช้ WillPopScope ตอนนี้มันถูก deprecated แล้ว — ยกคำมาจาก annotation ตรง ๆ จาก Flutter SDK 3.47.5 ที่ติดตั้งอยู่ (packages/flutter/lib/src/widgets/will_pop_scope.dart):
@Deprecated('Use PopScope instead. The Android predictive back feature will not work with WillPopScope. This feature was deprecated after v3.12.0-1.0.pre.')
ตัวแทนคือ PopScope<T> ซึ่งมี member สำคัญสองตัว (ยกจาก SDK ที่ติดตั้งอยู่เช่นกัน packages/flutter/lib/src/widgets/pop_scope.dart):
canPop(bool, defaulttrue) — ถ้าfalse, system back gesture (รวมถึง preview ของ predictive back) จะถูกปิดสำหรับ route นี้onPopInvokedWithResult(PopInvokedWithResultCallback<T>?) — ถูกเรียก หลัง จากที่ pop ถูกจัดการแล้ว พร้อมdidPop(เกิดขึ้นจริงหรือไม่) และresultของ pop มันยัง fire ด้วยdidPop: falseเมื่อcanPop: falseบล็อกการ pop ไว้ — นี่ไม่ใช่ callback แบบ “veto”canPopต่างหากที่เป็นตัว veto
แต่ก่อนมี onPopInvoked (ไม่มี Result) — ตัวมันเองก็ถูก deprecated บน PopScope เช่นกัน ยกจากไฟล์เดียวกัน: @Deprecated('Use onPopInvokedWithResult instead. This feature was deprecated after v3.22.0-12.0.pre.')
import 'package:flutter/material.dart';
/// Records `onPopInvokedWithResult` calls so the test can assert on them —/// a top-level list plays the same role `lastPoppedResult` played above.final popScopeEvents = <String>[];
class PopScopeHost extends StatelessWidget { const PopScopeHost({super.key}); @override Widget build(BuildContext context) { return Scaffold( body: TextButton( onPressed: () => Navigator.of(context).push( MaterialPageRoute<void>(builder: (context) => const PopScopeDemo()), ), child: const Text('Root — push guarded screen'), ), ); }}
class PopScopeDemo extends StatefulWidget { const PopScopeDemo({super.key}); @override State<PopScopeDemo> createState() => _PopScopeDemoState();}
class _PopScopeDemoState extends State<PopScopeDemo> { bool guardEnabled = true;
@override Widget build(BuildContext context) { return PopScope( canPop: !guardEnabled, onPopInvokedWithResult: (bool didPop, Object? result) { popScopeEvents.add(didPop ? 'popped' : 'blocked'); }, child: Scaffold( body: Column( mainAxisSize: MainAxisSize.min, children: [ const Text('Guarded screen'), TextButton( onPressed: () => setState(() => guardEnabled = false), child: const Text('Disable guard'), ), ], ), ), ); }}import 'package:flutter/material.dart';import 'package:flutter_test/flutter_test.dart';import 'package:my_app/pop_scope_demo.dart';
void main() { testWidgets('PopScope(canPop:false) blocks the pop and still fires onPopInvokedWithResult(false, ...); disabling the guard lets it through', (tester) async { popScopeEvents.clear(); await tester.pumpWidget(const MaterialApp(home: PopScopeHost())); await tester.tap(find.text('Root — push guarded screen')); await tester.pumpAndSettle(); expect(find.text('Guarded screen'), findsOneWidget);
final navigator = tester.state<NavigatorState>(find.byType(Navigator).first);
navigator.maybePop(); await tester.pumpAndSettle(); expect(find.text('Guarded screen'), findsOneWidget, reason: 'canPop:false blocked the pop — still on the guarded screen'); expect(popScopeEvents, ['blocked'], reason: 'the callback still fires, with didPop:false — it reports, it does not itself veto');
await tester.tap(find.text('Disable guard')); await tester.pump(); navigator.maybePop(); await tester.pumpAndSettle(); expect(find.text('Root — push guarded screen'), findsOneWidget, reason: 'guard disabled — the pop went through this time'); expect(popScopeEvents, ['blocked', 'popped']); });}✓ PopScope(canPop:false) blocks the pop and still fires onPopInvokedWithResult(false, ...); disabling the guard lets it through
00:00 +1: All tests passed!predictive back บน Android
หัวข้อที่มีชื่อว่า “predictive back บน Android”ตั้งแต่ Flutter 3.38 page transition default บน TargetPlatform.android คือ PredictiveBackPageTransitionsBuilder — ชื่อ class จริง ยืนยันตรงกับ SDK source ที่ติดตั้งอยู่ (packages/flutter/lib/src/material/page_transitions_theme.dart, map _defaultBuilders: TargetPlatform.android: PredictiveBackPageTransitionsBuilder()) มันเดินตาม back gesture ของผู้ใช้และ preview หน้าปลายทางข้างล่างให้เห็นระหว่างลาก คุณได้มันมาฟรีจาก MaterialPageRoute ไม่ต้องแก้ code เพราะ transition ถูกอ่านมาจาก PageTransitionsTheme
เฉพาะบน Android, preview ของ predictive back ระดับ OS (เฟรมที่ลอกออกให้เห็นหน้าก่อนหน้า/หน้า home) ต้องการ flag เปิดใช้เพิ่มอีกอันหนึ่ง ตาม คู่มือ predictive back ของ docs.flutter.dev:
<!-- android/app/src/main/AndroidManifest.xml, inside <activity> -->android:enableOnBackInvokedCallback="true"และ Android 14 (API 34) ขึ้นไปเปิด animation ของ predictive back ระดับระบบเป็น default ทันทีที่ flag นี้ถูกตั้ง PopScope — ไม่ใช่ WillPopScope ที่ deprecated แล้ว — คือตัวที่ทำให้ logic การดัก back ของ route เข้ากันได้กับ gesture นี้: หน้า docs เดียวกันบอกตรง ๆ ว่า “PopScope replaces the deprecated WillPopScope widget and supports predictive back gestures.”
Navigator 2.0: Router, RouterDelegate, RouteInformationParser
หัวข้อที่มีชื่อว่า “Navigator 2.0: Router, RouterDelegate, RouteInformationParser”ทั้งหมดข้างบนคือ Navigator 1.0 — การเรียก push/pop แบบ imperative Flutter ยังมีระบบ routing แบบ declarative ด้วย เรียกว่า Navigator 2.0 ประกอบจากสามชิ้นที่ทำงานร่วมกัน (api.flutter.dev/flutter/widgets/Router-class.html):
Router— ตัว dispatcher มันฟัง OS เพื่อรับข้อมูล routing (deep link, browser URL,pushNamed) แล้วส่งต่อRouteInformationParser— แปลงข้อมูล routing ดิบนั้นเป็น typed app-state object ของคุณเองRouterDelegate— เอา typed state นั้นมาสร้างNavigatorจริง (list ของPage) ที่สอดคล้องกัน และยังต้อง report state กลับ เมื่อNavigatorเอง pop อะไรบางอย่าง (เช่น system back gesture) เพื่อให้ URL/OS routing state กับ widget tree sync กันทั้งสองทาง
flowchart LR os["OS routing info (deep link, browser URL)"] --> router["Router"] router --> parser["RouteInformationParser (raw -> typed state)"] parser --> delegate["RouterDelegate (typed state -> Navigator/Page list)"] delegate --> nav["Navigator"] nav -->|"pop reported back"| delegate
นี่คือพลังจริง (URL-driven navigation, ควบคุม Page stack ทั้งหมดแบบเจาะจงสำหรับ state restoration และ deep link) แลกมาด้วย boilerplate จริง ๆ — คุณต้องเขียน RouteInformationParser กับ RouterDelegate เอง ต่อ ChangeNotifier เข้า delegate ให้มัน rebuild เมื่อ state เปลี่ยน แล้ว reimplement สิ่งที่ Navigator 1.0 ให้คุณมาฟรี นี่คือเหตุผลตรง ๆ ที่ ecosystem ไม่เขียน RouterDelegate subclass เองสำหรับแอปทั่วไป: คำอธิบายของ go_router เองบน pub.dev บอกตรง ๆ — go_router “is a declarative routing package for Flutter that uses the Router API to provide a convenient, url-based API for navigating between different screens.” go_router คือ คู่ RouterDelegate/RouteInformationParser ที่ package เขียนไว้ให้ครั้งเดียว คุณแค่ config มันด้วย list ของ GoRoute แทนที่จะเขียน delegate เอง — บทถัดไปคือ API นั้น
Navigator.of(context) นอก Navigator — error จริง และ rootNavigator: true
หัวข้อที่มีชื่อว่า “Navigator.of(context) นอก Navigator — error จริง และ rootNavigator: true”Navigator.of(context) เดินขึ้น widget tree จาก context เพื่อหา Navigator ที่ใกล้ที่สุด ถ้าไม่มีเลย มันจะ throw — ข้อความจริง ยกมาจาก SDK ที่ติดตั้งอยู่ (packages/flutter/lib/src/widgets/navigator.dart) ไม่ต้องมี app code เพิ่มเพื่อพิสูจน์เลย — test เรียก Navigator.of() ตรง ๆ กับ context ที่ไม่มี Navigator เป็น ancestor เลย:
import 'package:flutter/material.dart';import 'package:flutter/widgets.dart';import 'package:flutter_test/flutter_test.dart';
void main() { testWidgets('Navigator.of(context) throws FlutterError with the real message when there is no Navigator ancestor', (tester) async { late BuildContext capturedContext; await tester.pumpWidget(Builder(builder: (context) { capturedContext = context; return const SizedBox.shrink(); }));
expect( () => Navigator.of(capturedContext), throwsA( isA<FlutterError>().having( (e) => e.toString(), 'message', contains('Navigator operation requested with a context that does not include a Navigator.'), ), ), ); });}✓ Navigator.of(context) throws FlutterError with the real message when there is no Navigator ancestor
00:00 +1: All tests passed!วิธีที่มันเกิดขึ้นบ่อยที่สุดในโค้ดจริงไม่ใช่ “ลืม MaterialApp” — แต่คือการเรียก Navigator.of(context) ด้วย context จาก เหนือ route ที่คุณเพิ่ง push (เช่น context ที่ callback closure ไว้ก่อนจะ push แทนที่จะเป็น context ของหน้าจอที่ push เอง) หรือจากใน widget ที่กำลังถูก build เป็น ส่วนหนึ่งของ route transition เอง ก่อนที่ subtree นั้นจะถูก attach เข้าไปใต้ Navigator ใด ๆ
rootNavigator: true แก้ปัญหาตรงข้ามกัน: มี Navigator เยอะเกินไป ให้เลือก widget ตัวหนึ่งสามารถฝัง Navigator ของตัวเอง (wizard, back-stack ของ tab ที่ nest อยู่, Navigator ที่ showDialog สร้างขึ้นภายใน) — โดย default Navigator.of(context) จะหาตัวที่ ใกล้ที่สุด แต่บางทีคุณต้องการตัวที่อยู่นอกสุดแทน (เพื่อ pop full-screen dialog จากลึกเข้าไปในเนื้อหาของมัน เป็นต้น)
import 'package:flutter/material.dart';
/// A widget that embeds its OWN nested Navigator — the shape of an in-page/// wizard, a nested tab's own back-stack, or what showDialog builds internally.class RootNavigatorDemo extends StatelessWidget { const RootNavigatorDemo({super.key}); @override Widget build(BuildContext context) { return Scaffold( body: Navigator( onGenerateRoute: (settings) => MaterialPageRoute( builder: (context) => const Center(child: Text('Inner navigator route')), ), ), ); }}import 'package:flutter/material.dart';import 'package:flutter_test/flutter_test.dart';import 'package:my_app/root_navigator_demo.dart';
void main() { testWidgets('rootNavigator:true resolves to the OUTERMOST Navigator, not the nearest one', (tester) async { await tester.pumpWidget(const MaterialApp(home: RootNavigatorDemo())); await tester.pumpAndSettle();
final innerContext = tester.element(find.text('Inner navigator route')); final nearest = Navigator.of(innerContext); final root = Navigator.of(innerContext, rootNavigator: true);
expect(identical(nearest, root), isFalse, reason: 'two distinct NavigatorState instances answer the same context'); expect( identical(root, tester.state<NavigatorState>(find.byType(Navigator).first)), isTrue, reason: 'rootNavigator:true walks all the way out — to MaterialApp\'s own Navigator, the first one built', ); });}✓ rootNavigator:true resolves to the OUTERMOST Navigator, not the nearest one
00:00 +1: All tests passed!ส่งข้อมูลไปข้างหน้า และ named route เทียบ onGenerateRoute
หัวข้อที่มีชื่อว่า “ส่งข้อมูลไปข้างหน้า และ named route เทียบ onGenerateRoute”วิธีที่สะอาดที่สุดในการส่งข้อมูลไปหน้าจอถัดไปคือ constructor argument ธรรมดา — ไม่มีเวทมนตร์ ไม่มี global state:
Navigator.of(context).push( MaterialPageRoute( builder: (context) => DetailScreen(userId: 42), ),);
class DetailScreen extends StatelessWidget { const DetailScreen({super.key, required this.userId}); final int userId;}สำหรับแอปเล็ก ๆ คุณลงทะเบียน named route ใน MaterialApp.routes แล้ว navigate ด้วยชื่อที่เป็น string ได้ — วิธีนี้รวม route table ไว้ที่เดียว:
MaterialApp( routes: { '/': (context) => const HomeScreen(), '/settings': (context) => const SettingsScreen(), },);
Navigator.of(context).pushNamed('/settings');routes เป็น map ตายตัวที่ไม่มีทางเข้าถึง argument ของ route ที่ push ภายใน signature ของ builder เอง onGenerateRoute คือทางออก: function ที่รับ RouteSettings เต็ม ๆ (ทั้งชื่อ และ arguments) แล้ว return Route ที่คุณสร้างเอง รวมถึงเลือก transition หรือ fallback ไปหน้า 404 สำหรับชื่อที่ไม่ match ได้ด้วย:
MaterialApp( onGenerateRoute: (settings) { if (settings.name == '/details') { final id = settings.arguments as int; return MaterialPageRoute(builder: (context) => DetailScreen(userId: id)); } return MaterialPageRoute(builder: (context) => const NotFoundScreen()); },);ทั้งสองแบบยังคงเป็น string-keyed และ untyped ที่ call site (settings.arguments as int คือ runtime cast ไม่ใช่การรับประกันตอน compile-time) และไม่มีคำตอบระดับ first-class สำหรับ deep link ที่มีรูปแบบ URL จริง (/users/42/posts/7) — คุณต้อง parse settings.name เอง นั่นคือช่องว่างที่ทีมแนะนำ go_router สำหรับแอปที่มีหน้าจอเกินหยิบมือเดียว: มันให้ typed path parameter, nested/shell route และ redirect-based guard บน Router API เดียวกันที่อธิบายไว้ข้างบน โดยไม่ต้องเขียน RouterDelegate เอง — บทถัดไป
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”คิดว่า Navigator คือ List<Route> ที่มี mutation ที่อนุญาตแค่สองแบบ คือ push/pop บวกกับ convenience ที่มีชื่อไม่กี่ตัวที่สร้างจากมัน (pushReplacement = pop-แล้ว-push-ในเฟรมเดียวที่ top; pushNamedAndRemoveUntil = push แล้ว pop ทุกอันที่ไม่ผ่าน predicate) ทุก route คือ element ใน list นั้น — pushReplacement ไม่แตะอะไรที่อยู่ใต้ top เลย นั่นคือเหตุผลที่ Home รอดจากการสลับของ pushReplacement ข้างบน แต่ไม่รอดจากการเคลียร์เต็ม ๆ ของ pushNamedAndRemoveUntil((route) => false) PopScope ไม่ได้อยู่ “ใน” list นั้นเลย — มันคือ widget ข้างใน subtree ของ route เองที่มีสิทธิ์ออกเสียง (canPop) ว่าการขอ pop กับ route นั้นโดยเฉพาะ จะถูกยอมหรือไม่ และรายงาน (onPopInvokedWithResult) ไม่ว่าผลจะเป็นอะไร
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”| ตัวเลือก | ข้อดี | ต้นทุน |
|---|---|---|
Navigator 1.0 (push/pop, MaterialPageRoute) | ตั้งค่าน้อยที่สุด เรียนรู้ง่าย เหมาะกับ flow ง่าย ๆ | ไม่รองรับ URL, deep link ต้อง parse เอง, stack state ไม่ serialize ได้เองตามธรรมชาติ |
Named route (MaterialApp.routes) | route table รวมศูนย์ เขียนสั้น | ไม่มี typed argument ไม่มีทางเข้าถึง argument ต่อ route ใน builder |
onGenerateRoute | ควบคุมได้เต็มต่อ route (argument, transition, fallback 404) | ยังเป็น string-keyed, ยังต้อง runtime cast สำหรับ argument, จัดการ 404 เอง |
Navigator 2.0 (Router/RouterDelegate เขียนเอง) | ควบคุมแบบ declarative เต็มรูปแบบ, URL sync จริง, Page stack ที่แม่นยำสำหรับ state restoration | boilerplate จริง — RouterDelegate + RouteInformationParser ที่คุณเขียนและดูแลเอง |
| go_router (บทถัดไป) | พลังของ Router 2.0 ผ่าน API แบบ list GoRoute, typed path, redirect guard | ต้องตาม version package เพิ่มอีกตัว; ยังคุ้มที่จะรู้ Navigator 1.0/2.0 เพื่อเข้าใจว่ามันห่ออะไรอยู่ |
Common Misconceptions
หัวข้อที่มีชื่อว่า “Common Misconceptions”- “
WillPopScopeยังใช้ได้ดี แค่เก่าไปหน่อย” มันคือ@Deprecatedใน SDK 3.47.5 ที่ติดตั้งอยู่ และตาม docs มันรองรับ predictive back ไม่ได้โดยโครงสร้าง — annotation บอกตรง ๆ ว่า “The Android predictive back feature will not work with WillPopScope.” - “
onPopInvokedWithResultของPopScopeป้องกันการ pop ได้” มันทำไม่ได้ — มัน fire หลัง ที่ pop ถูกตัดสินใจแล้วcanPopเท่านั้นที่เป็นตัว veto พิสูจน์แล้วข้างบน (popScopeEventsบันทึก'blocked'จาก callback ในขณะที่หน้าจอไม่เปลี่ยนเลย) - “
pushReplacementเคลียร์ทั้ง stack” มันสลับแค่ entry เดียว — ตัว top เท่านั้น ทุกอย่างข้างล่างไม่ถูกแตะ พิสูจน์แล้วข้างบน (canPop()ยังtrueและการ pop Settings เผย Home ตรง ๆ) - “Named route กับ
onGenerateRouteให้ argument แบบ type-safe” ทั้งสองส่งObject?/argumentsผ่านRouteSettings— ทุกครั้งที่อ่านคือ runtime cast ไม่ใช่การรับประกันตอน compile-time - “
Navigator.of(context)หา Navigator ตัวเดียวกันเสมอไม่ว่าจะส่ง context ไหน” มันหาตัวที่ ใกล้ที่สุด และ widget สามารถ nest ของตัวเองได้ — พิสูจน์แล้วข้างบน (nearestกับrootเป็นNavigatorStateคนละตัวกันจริงสำหรับ context เดียวกัน)
Reading AI-generated code
หัวข้อที่มีชื่อว่า “Reading AI-generated code”pattern ที่ AI-generated เจอบ่อยมาก: helper แบบ async “save แล้วกลับไปหน้าก่อน” ที่อ่านดูสะอาดแต่ถือ BuildContext ข้าม await
// AI-generated: "save then go back"Future<void> saveAndClose(BuildContext context) async { await saveToDatabase(); Navigator.of(context).pop();}ผลลัพธ์จริงจาก flutter analyze บน snippet นี้ตรง ๆ จาก probe นี้:
info • Don't use 'BuildContext's across async gaps. Try rewriting the code to not use the 'BuildContext', or guard the use with a 'mounted' check • lib/bug.dart:5:16 • use_build_context_synchronouslywidget ที่เรียก saveAndClose อาจถูก pop, unmount หรือแทนที่ระหว่าง await saveToDatabase() — การใช้ context ของมันหลังจากนั้นคือ behavior ที่ไม่แน่นอน (อาจ throw หรือชี้ไปที่ element ที่ตายไปแล้วอย่างเงียบ ๆ) วิธีแก้คือ mounted guard แบบเดียวกับที่โมดูล state-management พิสูจน์ไว้สำหรับ setState แค่เอามาใช้กับ Navigator.of(context) แทน:
Future<void> saveAndClose(BuildContext context) async { await saveToDatabase(); if (!context.mounted) return; // guard the BuildContext use after the await Navigator.of(context).pop();}🐛 หา bug ในโค้ดนี้
// AI-generated: "guard the exit with a confirmation dialog"class EditScreen extends StatelessWidget {const EditScreen({super.key});@overrideWidget build(BuildContext context) { return WillPopScope( onWillPop: () async { final leave = await showExitDialog(context); return leave ?? false; }, child: const Scaffold(body: Text('Editing...')), );}}เฉลย
โค้ดนี้ compile ผ่าน (พร้อม deprecation warning) แต่ผิดด้วยสองเหตุผลแยกกัน อย่างแรก WillPopScope เป็น @Deprecated ตั้งแต่ v3.12.0-1.0.pre — annotation บอกตรง ๆ ว่า “the Android predictive back feature will not work” กับมัน ดังนั้นบน Android 14+ ที่เปิด predictive back ไว้ การยืนยันออกจากหน้าจะถูกข้ามอย่างเงียบ ๆ โดย preview ระดับ OS ไม่ถูกดักไว้เลย อย่างที่สอง แม้จะไม่นับเรื่อง deprecation onWillPop ตอบได้แค่ หลังจากรู้ผลแล้ว เท่านั้น (Future<bool>) — มันแสดง live preview ระหว่าง gesture ไม่ได้ ซึ่งเป็นประเด็นหลักของ predictive back เลย วิธีแก้คือ PopScope(canPop: false, onPopInvokedWithResult: (didPop, result) async { if (didPop) return; final leave = await showExitDialog(context); if (leave == true && context.mounted) Navigator.of(context).pop(); }) — canPop: false บล็อกการ pop ไว้ล่วงหน้า (เข้ากันได้กับ preview ของ predictive back) และ dialog รันก็ต่อเมื่อมีความพยายาม pop จริงที่ถูกบล็อกไปแล้วเท่านั้น