--- title: 'Flutter 導航&路由' disqus: kyleAlien --- Flutter 導航&路由 === ## Overview of Content **路由(Route)管理就是在 Flutter 移動開發中通常是指頁面的跳轉**,如同我們在開發 Android 時不同的 Module 跳轉就需要一個路由器,而 Flutter 的頁面跳轉也有自己的路由器風格 :::success * 如果喜歡讀更好看一點的網頁版本,可以到我新做的網站 [**DevTech Ascendancy Hub**](https://devtechascendancy.com/) 本篇文章對應的是 [**深入解析 Flutter Navigator:常見錯誤、解決方法與路由跳轉技巧、動畫**](https://devtechascendancy.com/flutter-navigator-guide_routing-animation/) ::: :::info 以下使用的 Flutter 版本為 `3.22.2` ::: [TOC] ## 認識 Navigator Fluttter 使用 Navigator 類來跳轉頁面 ### Navigator 錯誤的使用方式 * **以下的範例是一個錯誤的範例**,接下來我們會分析這個錯誤,並將它導向正確 > 以下是程式碼目的是 Page1 頁面,跳轉到 Page2 頁面 1. Page1 頁面:在這個頁面中做個簡單的 Btn 來跳轉到 Page2 頁面 ```dart= class Page1 extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( home: Scaffold( appBar: AppBar(title: Text("主頁面")), body: RaisedButton( child: Text("跳轉 Page2"), onPressed: () { debugPrint("點擊跳轉按鈕"); /// Navigator 也是 Widget // 跳轉到 Page2 頁面 Navigator.of(context).push( MaterialPageRoute( // Route 是抽象類 builder: (BuildContext context) { return Page2(); } ) ); }, ) ), ); } } ``` 2. Page2 頁面 ```dart= class Page2 extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( home: Scaffold( appBar: AppBar(title: Text("頁面")), body: Text("頁面 2"), ), ); } } ``` **--出錯結果--** :::warning 錯誤:`Navigator operation requested with a context that does not include a Navigator.`(使用不包含導航器的上下文請求導航器操作) ::: > ![](https://i.imgur.com/5b3wk1h.png) ### Navigator 錯誤分析 * 接下來分析 Navigator 錯誤,帶到 `Navigator#of` 方法,就可以發現我們剛剛在外部看得完全一樣的錯誤訊息,並且還可以發現以下事情 1. 在進入 `Navigator#of` 方法時,它會先獲取 `NavigatorState` 物件,而獲取的方法有三種… * 透過 BuildContext#`state` 獲取 * 透過 BuildContext#`findRootAncestorStateOfType` 獲取 * 透過 BuildContext#`findAncestorStateOfType` 獲取(等等往下分析) > 等等主要分析 `findAncestorStateOfType` 函數,操作就像是再呼叫 BuildContext 的父類別,並去尋找其中的 `NavigatorState` 物件 ```dart= // navigator.dart 的源碼 static NavigatorState of( BuildContext context, { bool rootNavigator = false, }) { // Handles the case where the input context is a navigator element. NavigatorState? navigator; if (context is StatefulElement && context.state is NavigatorState) { navigator = context.state as NavigatorState; } if (rootNavigator) { navigator = context.findRootAncestorStateOfType<NavigatorState>() ?? navigator; } else { navigator = navigator ?? context.findAncestorStateOfType<NavigatorState>(); } ... 省略 } ``` 2. 透過 `assert()` 斷言來確保有取得 `NavigatorState` 物件 ```dart= // navigator.dart 的源碼 static NavigatorState of( BuildContext context, { bool rootNavigator = false, }) { ... 省略 assert(() { if (navigator == null) { throw FlutterError( 'Navigator operation requested with a context that does not include a Navigator.\n' 'The context used to push or pop routes from the Navigator must be that of a ' 'widget that is a descendant of a Navigator widget.', ); } return true; }()); return navigator!; } ``` 3. 進入 BuildContext#`findAncestorStateOfType` 方法,我們可以發現以下事情 * BuildContext#`findAncestorStateOfType` 方法的實作類是 `Element` 類 ```mermaid classDiagram BuildContext <|-- Element: 繼承 BuildContext: State state BuildContext: +(T extends State) findAncestorStateOfType() State <.. BuildContext: 依賴、關聯 ``` * 並且 Element 內部有一個自身類型的成員 `_parent`,而 `findAncestorStateOfType` 方法則透過「鏈結」的方式不斷去尋找其父類別中的哪個類別屬於 T 屬性(也就是尋找 `NavigatorState` 物件) :::info * 這種設計模式就是 [Chain 責任鏈模式](https://devtechascendancy.com/object-oriented_design_chain_framework/),有興趣的人可以點擊連結了解這種設計模式 ```mermaid classDiagram BuildContext <|-- Element: 繼承 BuildContext: State state BuildContext: +(T extends State) findAncestorStateOfType() State <.. BuildContext: 依賴、關聯 Element <|-- Element Element: -Element _parent ``` ::: ```dart= // 可以發現該方法為 abstract 抽象方法 (framework.dart 中的 BuildContext) T findAncestorStateOfType<T extends State>(); // -------------------------------------------------------------------- // 以下是該方法的實做 (framework.dart 中的 Element) abstract class Element extends DiagnosticableTree implements BuildContext { Element? _parent; ... @override T findAncestorStateOfType<T extends State<StatefulWidget>>() { assert(_debugCheckStateIsActiveForAncestorLookup()); // "1. " 獲取父節點 Element ancestor = _parent; while (ancestor != null) { if (ancestor is StatefulElement && ancestor.state is T) break; // 2. 不斷往上找 ancestor = ancestor._parent; } final StatefulElement statefulAncestor = ancestor as StatefulElement; return statefulAncestor?.state as T; } ... } ``` :::success * **Context & Elements 關係是什麼**? 1. Elements 這個類是實現了 BuildContext 類 > 可以發現 BuildContext 的抽象由 Element 實做… 也就是實際顯示的 View (**Widget 最終會創建出 Element 元件**) > ![](https://i.imgur.com/5wpei1b.png) 2. 在我們定義的 StatefulWidget、StatelessWidget 中所拿到的 BuildContext 就是 Elements 的 BuildContext > ![](https://i.imgur.com/sxUoBCp.png) 3. StatefulWidget、StatelessWidget 中的 context 其實就是當前頁面 > ![](https://i.imgur.com/pfkfrZT.png) ::: :::info * 最終可以發現 Navigator 跳轉的關鍵在於 BuildContext 上下文的尋找到 `state` 成員為 `NavigatorState` 物件才可以正常跳轉 ::: * 使用 Debug 模式來觀察 BuildContext#`_parent` 是哪個類,可以發現 Page1 它的父親是 RenderView (Root View),它類似於 Android 布局中的 Decor View(畫面的根佈局) > ![](https://i.imgur.com/dM9ksUK.png) 而 RenderView(Root View)的上一層就沒有其他的 Element,導致返回為 null,這也是為何無法正常跳轉的原因,因為在所有父類中根本找不到有哪個類是 `NavigatorState` 物件 > ![](https://i.imgur.com/sLBr01s.png) :::warning * 嘗試改為 StatelessWidget 修改為 StatefulWidget 有用嗎? 嘗試改為 StatefulWidget,會發現不管用,因為**不管是 StatelessWidget / StatefulWidget 都是相同的 RootView** ```dart= // 該程式輸出結果同樣錯誤,無法跳轉 void main() => runApp(FixFul()); class FixFul extends StatefulWidget { @override FixFulState createState() => FixFulState(); } class FixFulState extends State<FixFul> { @override Widget build(BuildContext context) { return MaterialApp( home: Scaffold( appBar: AppBar(title: Text("主頁面")), body: RaisedButton( child: Text("跳轉 Page2"), onPressed: () { debugPrint("點擊跳轉按鈕"); /// Navigator 也是 Widget Navigator.of(context).push( MaterialPageRoute( // Route 是抽象類 builder: (BuildContext context) { return Page2(); } ) ); }, ) ), ); } } ``` ::: ### Navigator 跳轉失敗的解決方法:自訂 Navigator、層級分離 * 透過上面 Navigator 跳轉失敗的原因,我們就可以知道,是因為父類中沒有任何一個類有實作 `NavigatorState` 類,這才導致跳轉失敗,而解決這個問題的方法有以下幾中 1. 自己定義 Navigator:自己創建一個 `GlobalKey<NavigatorState>` 物件,並設定給 MaterialApp#`navigatorKey` 成員 ```dart= import 'package:flutter/material.dart'; void main() => runApp(Page1()); class Page1 extends StatelessWidget { /// 自定義導航 Key final GlobalKey<NavigatorState> navigatorKey = GlobalKey(); @override Widget build(BuildContext context) { return MaterialApp( /// 賦予 MaterialApp Key navigatorKey: navigatorKey, home: Scaffold( appBar: AppBar(title: Text("主頁面")), body: RaisedButton( child: Text("跳轉 Page2"), onPressed: () { debugPrint("navigatorKey type: ${navigatorKey.currentWidget.runtimeType.toString()}"); navigatorKey.currentState.push(MaterialPageRoute( builder: (BuildContext context) { return Page2(); } )); }, ) ) ); } } class Page2 extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( home: Scaffold( appBar: AppBar(title: Text("頁面")), body: RaisedButton( child: Text("返回頁面"), onPressed: (){ Navigator.pop(context); // 回到上個頁面 }, ), ), ); } } ``` 2. **層級進行分離**:透過在 MaterialApp 與 Page1 中間夾帶另一個 Widget 的方式進行跳轉,以下舉兩個使用層級分離的方案 * 透過 `Builder` 來包裝頁面:透過 Builder 中的 `builder` 成員給予的 BuildContext 來跳轉頁面 ```dart= import 'package:flutter/material.dart'; void main() => runApp(Page1()); class Page1 extends StatelessWidget { Widget builder(BuildContext context) { return Scaffold( appBar: AppBar(title: Text("主頁面")), body: RaisedButton( child: Text("跳轉 Page2"), onPressed: () { debugPrint("點擊跳轉按鈕"); /// 此時修改傳入的 context 就是 Builder,Builder 的 Parent 就是 MaterialApp Navigator.of(context).push( MaterialPageRoute( builder: (BuildContext context) { return Page2(); } ) ); }, ) ); } @override Widget build(BuildContext context) { return MaterialApp( // Builder 是一個基礎 Widget home: Builder( builder: builder, ) ); } } class Page2 extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( home: Scaffold( appBar: AppBar(title: Text("頁面")), body: Text("頁面 2"), ), ); } } ``` **--實做結果--** > ![](https://i.imgur.com/rGgkNkt.png) :::info * 而 Builder 並非一定要放置在範例程式中的位置,**觀念是 Context 上下文的尋找,++只要放置在 MaterialApp 的下層即可++** ::: * **自訂中間層**:Builder 是個 Widget,而我們也可以自訂一個 Widget ```dart= import 'package:flutter/material.dart'; void main() => runApp(Page1()); class CoverPage extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text("主頁面")), body: RaisedButton( child: Text("跳轉 Page2"), onPressed: () { debugPrint("點擊跳轉按鈕"); /// 此時傳入的 context 就是 CoverPage's context,其上一級就是 Material App Navigator.of(context).push( MaterialPageRoute( builder: (BuildContext context) { return Page2(); } ) ); }, ) ); } } class Page1 extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( home: CoverPage() ); } } class Page2 extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( home: Scaffold( appBar: AppBar(title: Text("頁面")), body: Text("頁面 2"), ), ); } } ``` **--實做結果--** > ![](https://i.imgur.com/buWBxVD.png) ### 層級分離概念 * 接著我們也會會好奇為什麼只要套用在自己的 Widget 與 MaterialApp 中間夾帶一層中間層 Widget 就可以就可以正常跳轉? 在最上面我們失敗的實驗中可以看到 Navigator.of 的操作是往父類 Widget 搜尋 Router(它預設不會使用搜尋創建的 Router),而 MaterialApp 的父類 Widget 就是根佈局 > 根佈局(RenderView)不會有 Router 可用 ```mermaid graph LR subgraph RenderView render_view subgraph MaterialApp Navigator.of Router context end end Navigator.of -.-> |往上層找| context context -.-> |找不到 Router| render_view ``` * 使用層級分離的技巧後,Navigator.of 的上下文就會在層級內部,這就導致 Navigator.of 可以找到 MaterialApp 的 Router ```mermaid graph LR subgraph RenderView subgraph MaterialApp Router subgraph 層級 Widget Navigator.of context end end render_view end Navigator.of -.-> |往上層找| context context -.-> |找到 Router| Router ``` ### 深究 MaterialApp 創建的 NavigatorState * MaterialApp 本身其實就在創建時附帶有路由器(`NavigatorState`)的功能,而這裡我們就來挖掘一下 MaterialApp 創建 NavigatorState 的時機點 MaterialApp 是一個 StatefulWidget,所以我們從它創建 State 的 `createState` 函數開始 :::success * 為什麼從 `createState` 函數開始? 因為這個 `createState` 函數創建出來的 State 會被賦予到 Element#`_state` 中,最終會影響到 BuildContext#`state`,也就會影響是否可以正確地找到 `NavigatorState` 物件 ::: 1. MaterialApp#`createState` 函數會創建 `_MaterialAppState` 物件 ```dart= // material/app.dart @override State<MaterialApp> createState() => _MaterialAppState(); ``` > ![image](https://hackmd.io/_uploads/SJfJbOpsC.png) 2. _MaterialAppState#`build` 函數會創建 `WidgetsApp` 物件 ```dart= // material/app.dart @override Widget build(BuildContext context) { Widget result = _buildWidgetApp(context); ... 省略部分 } Widget _buildWidgetApp(BuildContext context) { ... 省略部分 return WidgetsApp(...); } ``` 3. 而 WidgetsApp 也是 `StatefulWidget`,所以同樣從 `createState` 函數開始看… 可以看到它創建了 `_WidgetsAppState` 物件 ```dart= // widgets/app.dart @override State<WidgetsApp> createState() => _WidgetsAppState(); ``` 幾著繼續看 _WidgetsAppState#`build` 方法,可以看到在沒有代理的情況下,它會創建一個 `Navigator` 物件作為 Widget ```dart= // widgets/app.dart @override Widget build(BuildContext context) { Widget? routing; if (_usesRouterWithDelegates) { ... 省略 } else if (_usesNavigator) { assert(_navigator != null); routing = FocusScope( debugLabel: 'Navigator Scope', autofocus: true, child: Navigator( // 創建 Navigator 類 ... 省略 ), ); } ``` 4. 最後,我們可以在 Navigator 類中的 `createState` 函數中,看到 NavigatorState 被創建出來!! ```dart= // navigator.dart @override NavigatorState createState() => NavigatorState(); ``` > ![image](https://hackmd.io/_uploads/rJxmIOpi0.png) ## 命名路由 另外一種不透過層級分離的技巧就可以跳轉頁面的方式,就是透過設定「命名路由」來跳轉 ### 命名路由使用範例 * 路由針對每個頁面取名,然後**通過頁面名子傳遞給路由器就可以直接開啟**,跳轉頁面使用 Navigator#pushName 方法 範例如下: ```dart= import 'package:flutter/material.dart'; void main() => runApp(MyApp()); class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( title: "Flutter_Router_Demo", /// routes 原型:final Map<String, WidgetBuilder> /// WidgetBuilder 原型:typedef WidgetBuilder = Widget Function(BuildContext context); routes: { "/": (BuildContext context) => MainPage(), "Page1": (BuildContext context) => Page1(), }, ); } } class MainPage extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text("Build")), body: Column( children: <Widget>[ Text("主頁面"), RaisedButton( child: Text("跳轉"), onPressed: () { debugPrint("跳轉頁面"); Navigator.pushNamed(context, "Page1"); }, ) ], ), ); } } class Page1 extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text("Build")), body: Column( children: <Widget>[ Text("頁面二"), RaisedButton( child: Text("返回"), onPressed: () { debugPrint("返回頁面"); Navigator.pop(context); //Navigator.pushNamed(context, "/"); }, ) ], ), ); } } ``` **--實做結果--** > 跳轉成功 > > ![](https://i.imgur.com/H4Wu04o.png) :::warning * 命名路由中 `/`是一種特殊的名子,它代表了「**主頁面**」,有了 `/` 就不能使用 Material 的 home 屬性,否則就會報錯 > ![](https://i.imgur.com/q5sRCKE.png) ::: ## Navigator 跳轉結果 在 Android `StartActivityForResult` 可以查看跳轉的結果,而 Flutter 則更加的方便,在轉跳的 `Navigator#push` or `Navigator#pushName` 方法返回的是一個 Future 物件,配合使用 `async`、`await` 就可以堵塞並獲取結果 > ![](https://i.imgur.com/C3rSlrs.png) ### 取得 Navigator 跳轉結果:範例一 * 取得 Navigator 跳轉結果範例如下 1. 設定基礎的命名路由 ```dart= import 'package:flutter/material.dart'; void main() => runApp(MyApp()); class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( title: "Flutter_Router_Demo", /// routes 原型:final Map<String, WidgetBuilder> /// WidgetBuilder 原型:typedef WidgetBuilder = Widget Function(BuildContext context); routes: { "/": (BuildContext context) => MainPage(), "Page1": (BuildContext context) => Page1(), }, home: MainPage(), ); } } ``` 2. 設定跳轉頁面,並使用 `async`、`await` 關鍵字來等待該頁面返回的結果 ```dart= class MainPage extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text("Build")), body: Column( children: <Widget>[ Text("主頁面"), RaisedButton( child: Text("跳轉"), /// 若沒有使用 async await 則無法接收轉跳參數 onPressed: () async { debugPrint("跳轉頁面"); var r = await Navigator.pushNamed(context, "Page1"); debugPrint("Finish: $r"); }, ) ], ), ); } } ``` 3. 頁面在返回時使用 `Navigator.pop`,並設定返回的數據,在返回之後 `MainPage` 就可以取得 `Page1` 返回的數據 ```dart= class Page1 extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text("Build")), body: Column( children: <Widget>[ Text("頁面二"), RaisedButton( child: Text("返回"), onPressed: () { debugPrint("返回頁面"); /// 返回參數 Navigator.pop(context, "Page1 Finish"); }, ) ], ), ); } } ``` **--實做結果--** > ![](https://i.imgur.com/dnC5i6S.png) ### Navigator 返回數據:範例二 * Navigator 返回數據的第二個範例如下 ```dart= import 'package:flutter/material.dart'; void main() => runApp(MyFirstNavigator()); class MyFirstNavigator extends StatelessWidget { @override Widget build(BuildContext context) { return new MaterialApp( title: "Navigator", home: Scaffold( appBar: AppBar( title: const Text("First Page") ), body: Builder(builder: (context) { // 使用 Builder 的 Context return RaisedButton( onPressed: () { _waitNavigatorData(context); }, child: Text("Jump to second page."), ); }) ), ); } void _waitNavigatorData(BuildContext context) async { var push = await Navigator.push( context, MaterialPageRoute( builder: (context) => MySecondNavigator() ) ); Scaffold.of(context).showSnackBar( new SnackBar(content: new Text("$push")) ); } } class MySecondNavigator extends StatelessWidget { @override Widget build(BuildContext context) { return new MaterialApp( title: "Navigator", home: Scaffold( appBar: AppBar( title: const Text("First Page") ), body: Center( child: RaisedButton( onPressed: () { // 回傳數值 泛型 T Navigator.pop(context, "Hello Navigator"); }, child: Text("Return Msg."), ), ) ), ); } } ``` > ![](https://i.imgur.com/G4iIrWJ.png) ## 路由跳轉動畫 上面我們在 Router 中跳轉頁面是使用 Material 風格的動畫 (也就是 MaterialPageRoute 類) ### 自動路由跳轉動畫 * 如果要自訂動畫 **需要透過 `PageRouteBuilder` Widget 設定**,範例如下:以下動畫效果為「翻頁」第二頁由下至上反滾下來 ```dart= import 'package:flutter/material.dart'; void main() => runApp(MyApp()); class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return new MaterialApp( title: 'Flutter Demo', home: MainRoute(), ); } } class MainRoute extends StatelessWidget { Route routeWithAnimation() { return PageRouteBuilder( /// 設定動畫時間 transitionDuration: Duration(milliseconds: 1500), /// 原型如下: /// typedef RoutePageBuilder = Widget Function(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation); pageBuilder: (context, animation, secondaryAnimation) { /// SlideTransition 平移 return SlideTransition( /// Tween: 補間動畫 position: Tween<Offset>( /// 上至下的平移 begin: const Offset(0.0, -1.0), // 開始點 end: const Offset(0.0, 0.0), // 結束點 ).animate(animation), /// 目標跳轉界面 child: Page1(), ); } ); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text("Main Page")), body: Center ( child: Column( children: <Widget>[ Text("主頁面"), RaisedButton( child: Text("跳轉"), onPressed: () async { debugPrint("頁面跳轉"); var r = await Navigator.of(context).push( // 呼叫 route 動畫 routeWithAnimation() ); debugPrint("頁面跳轉結束:$r"); }, ) ], ), ), ); } } class Page1 extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text("Second Page")), body: Center ( child: Column( children: <Widget>[ Text("子頁面"), Builder( builder: (context) { return RaisedButton( child: Text("返回"), onPressed: () { Navigator.pop(context, "Hey~ I'm Second Page"); }, ); }, ), ], ), ), ); } } ``` ### 自訂跳轉組合動畫 * 在動畫中的 child 再嵌入動畫,最內層的動畫再呼叫目標頁面; 可依照這個概念在嵌套更多的動畫,範例如下:以下使用「**使用淡入動畫 + 平移動畫**」 ```dart= import 'package:flutter/material.dart'; void main() => runApp(MyApp()); class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return new MaterialApp( title: 'Flutter Demo', home: MainRoute(), ); } } class MainRoute extends StatelessWidget { Route myAnimation() { return PageRouteBuilder( /// 設定動畫時間 transitionDuration: Duration(milliseconds: 1500), /// 原型如下: /// typedef RoutePageBuilder = Widget Function(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation); pageBuilder: (context, animation, secondaryAnimation) { /// SlideTransition 平移 return SlideTransition( /// Tween: 補間動畫 position: Tween<Offset>( /// 左至右的平移 begin: const Offset(1.0, 0.0), // 開始點 end: const Offset(0.0, 0.0), // 結束點 ).animate(animation), /// 目標跳轉界面 child: new FadeTransition( opacity: animation, child: Page1(), ), ); } ); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text("Main Page")), body: Center ( child: Column( children: <Widget>[ Text("主頁面"), RaisedButton( child: Text("跳轉"), onPressed: () async { debugPrint("頁面跳轉"); var r = await Navigator.of(context).push( myAnimation() ); debugPrint("頁面跳轉結束:$r"); }, ) ], ), ), ); } } class Page1 extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text("Second Page")), body: Center ( child: Column( children: <Widget>[ Text("子頁面"), Builder( builder: (context) { return RaisedButton( child: Text("返回"), onPressed: () { Navigator.pop(context, "Hey~ I'm Second Page"); }, ); }, ), ], ), ), ); } } ``` ## Appendix & FAQ :::info ::: ###### tags: `Flutter`