Flutter Navigator 2.0 全面解析 随着最新版本的的发布,Flutter 1.22 中各个全新的功能映入了我们的眼帘,可以发现本次对路由相关 API 的改动最大,官方的设计文档中表示,由于传统的命令式并没有给开发者一种灵活的方式去直接管理路由栈 ,甚至觉得已经过时了,一点也不 Flutter。
As mentioned by a participant in one of Flutter’s user studies, the API also feels outdated and not very Flutter-y.
—— Flutter Navigator 2.0 and Router
而 Navigator 2.0 引入了一套全新的声明式 API,与以往不同,这类 API 可以实现用一组声明式的不可变的 Page 页面列表表示应用中的历史路由页面 ,从而转换成实际代码中 Navigator 的 Routes,这与 Flutter 中将不可变的 Widgets 解析成 Elements 并在页面中渲染的原理不谋而合 ,倒是 Flutter 十足。本文,我们就先从 Navigator 1.0 讲起逐步了解 Navigator 2.0 的实现方式。
Navigator 1.0 在 Navigator 2.0 推出之前,我们在应用中通常使用下面这两个类来管理各个页面:
Navigator ,管理一组 Route
对象。
Route ,一个 Route
通常可以表示一个页面,被 Navigator
持有,常用的两个实现类是 MaterialPageRoute
和 CupertinoPageRoute
。
Route
可以通过命名路由和组件路由(匿名路由)的方式被 push 进或者 pop 出 Navigator
的路由栈。我们先来简单回顾一下之前的传统用法。
组件路由 传统路由的实现中,Flutter 的各个页面由 Navigator
组织,彼此叠放成一个 “路由栈”,常用的根组件 MaterialApp
和CupertinoApp
底层就是通过 Navigator
来实现全局路由的管理的,我们可以通过 Navigator.of()
或者 Navigator.push()
、Navigator.pop()
等接口实现多个页面之间的导航,具体做法如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 import 'package:flutter/material.dart' ;void main() { runApp(Nav2App()); } class Nav2App extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( home: HomeScreen(), ); } } class HomeScreen extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(), body: Center( child: FlatButton( child: Text('View Details' ), onPressed: () { Navigator.push( context, MaterialPageRoute(builder: (context) { return DetailScreen(); }), ); }, ), ), ); } } class DetailScreen extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(), body: Center( child: FlatButton( child: Text('Pop!' ), onPressed: () { Navigator.pop(context); }, ), ), ); } }
当调用 push()
方法时,DetailScreen
组件就会被放置在 HomeScreen
的顶部,如图所示。
此时,上一个页面(HomeScreen
)仍在组件树中,因此当 DetailScreen
打开时它的状态依旧会被保留。
命名路由 Flutter 还支持通过命名路由的方式打开页面,各个页面的名称组成 “路由表” 通过参数(routes)传递给 MaterialApp
、CupertinoApp
,如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 import 'package:flutter/material.dart' ;void main() { runApp(Nav2App()); } class Nav2App extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( routes: { '/' : (context) => HomeScreen(), '/details' : (context) => DetailScreen(), }, ); } } class HomeScreen extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(), body: Center( child: FlatButton( child: Text('View Details' ), onPressed: () { Navigator.pushNamed( context, '/details' , ); }, ), ), ); } } class DetailScreen extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(), body: Center( child: FlatButton( child: Text('Pop!' ), onPressed: () { Navigator.pop(context); }, ), ), ); } }
使用命名路由时都需要预先定义好需要打开的页面,尽管我们也可以在各个页面之间传递数据,但这种方式原生并不支持直接解析路由参数,如不能使用 Web 应用中的链接形式 /details/:id
的路由名称。
onGenerateRoute 当然,我们可以使用 onGenerateRoute
来接受路由的完整路径,如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 onGenerateRoute: (settings) { if (settings.name == '/' ) { return MaterialPageRoute(builder: (context) => HomeScreen()); } var uri = Uri .parse(settings.name); if (uri.pathSegments.length == 2 && uri.pathSegments.first == 'details' ) { var id = uri.pathSegments[1 ]; return MaterialPageRoute(builder: (context) => DetailScreen(id: id)); } return MaterialPageRoute(builder: (context) => UnknownScreen()); },
完整代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 import 'package:flutter/material.dart' ;void main() { runApp(Nav2App()); } class Nav2App extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( onGenerateRoute: (settings) { if (settings.name == '/' ) { return MaterialPageRoute(builder: (context) => HomeScreen()); } var uri = Uri .parse(settings.name); if (uri.pathSegments.length == 2 && uri.pathSegments.first == 'details' ) { var id = uri.pathSegments[1 ]; return MaterialPageRoute(builder: (context) => DetailScreen(id: id)); } return MaterialPageRoute(builder: (context) => UnknownScreen()); }, ); } } class HomeScreen extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(), body: Center( child: FlatButton( child: Text('View Details' ), onPressed: () { Navigator.pushNamed( context, '/details/1' , ); }, ), ), ); } } class DetailScreen extends StatelessWidget { String id; DetailScreen({ this .id, }); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text('Viewing details for item $id ' ), FlatButton( child: Text('Pop!' ), onPressed: () { Navigator.pop(context); }, ), ], ), ), ); } } class UnknownScreen extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(), body: Center( child: Text('404!' ), ), ); } }
这里,我们可以通过 RouteSettings
类型的对象 settings
可以拿到 Navigator.pushNamed
调用时传入的参数。
Navigator 2.0 Navigator 2.0 提供了一系列全新的接口,可以实现将路由状态成为应用状态的一部分,并能够通过底层 API 实现参数解析的功能,新增的 API 如下:
Navigator 2.0 API 向框架添加了新类,以使应用程序的屏幕成为应用程序状态的功能,并提供解析来自底层平台(如 Web URL)的路由的功能。以下是新功能概述:
Page ,用来表示 Navigator
路由栈中各个页面的配置信息。
Router ,用来制定要由 Navigator
展示的页面列表,通常,该页面列表会根据系统或应用程序的状态改变而改变。
RouteInformationParser ,持有 RouteInformationProvider
提供的 RouteInformation
,可以将其解析为我们定义的数据类型。
RouterDelegate ,定义应用程序中的路由行为,例如 Router 如何知道应用程序状态的变化以及如何响应。主要的工作就是监听 RouteInformationParser
和应用状态并通过当前页面列表构建 ·。
BackButtonDispatcher ,响应后退按钮,并通知 Router
下图展示了 RouterDelegate
与 Router
、RouteInformationParser
以及用用状态的交互原理,
大致流程如下:
当系统打开新页面(如 “books / 2”
)时,RouteInformationParser
会将其转换为应用中的具体数据类型 T(如 BooksRoutePath
)。
该数据类型会被传递给 RouterDelegate
的 setNewRoutePath
方法,我们可以在这里更新路由状态(如通过设置 selectedBookId
)并调用 notifyListeners
响应该操作。
notifyListeners
会通知 Router
重建 RouterDelegate
(通过 build()
方法).
RouterDelegate.build()
返回一个新的 Navigator
实例,并最终展示出我们想要打开的页面(如 selectedBookId
)。
Navigator 2.0 实战 下面,我们就来使用 Navigator 2.0 做一个小小练习,我们将实现一个 Flutter 应用,该应用作用在 Web 上时路由状态会与浏览器中的 URL 连接保持一致,而且也能够处理浏览器的回退按钮,如下:
接下来,使用 flutter channel master
将 Flutter 切换到 master 版本,创建一个支持 Web 应用的 Flutter 项目 ,lib/main.dart
中的代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 import 'package:flutter/material.dart' ;void main() { runApp(BooksApp()); } class Book { final String title; final String author; Book(this .title, this .author); } class BooksApp extends StatefulWidget { @override State<StatefulWidget> createState() => _BooksAppState(); } class _BooksAppState extends State <BooksApp > { void initState() { super .initState(); } @override Widget build(BuildContext context) { return MaterialApp( title: 'Books App' , home: Navigator( pages: [ MaterialPage( key: ValueKey('BooksListPage' ), child: Scaffold(), ) ], onPopPage: (route, result) => route.didPop(result), ), ); } }
Pages Navigator
接受一个 pages
参数,如果 Page 列表发生变化,Navigator
也需要更新当前路由栈来保持同步,下面我们就来使用该性质,在新建的项目中开发一个可以展示书单列表的应用
_BooksAppState
中持有两个状态参数:书单列表和当前所选书籍:
1 2 3 4 5 6 7 8 9 10 11 class _BooksAppState extends State <BooksApp > { Book _selectedBook; bool show404 = false ; List <Book> books = [ Book('Stranger in a Strange Land' , 'Robert A. Heinlein' ), Book('Foundation' , 'Isaac Asimov' ), Book('Fahrenheit 451' , 'Ray Bradbury' ), ];
然后,在中_BooksAppState
,返回一个带有 Page
对象列表的 Navigator
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 @override Widget build(BuildContext context) { return MaterialApp( title: 'Books App' , home: Navigator( pages: [ MaterialPage( key: ValueKey('BooksListPage' ), child: BooksListScreen( books: books, onTapped: _handleBookTapped, ), ), ], ), ); } void _handleBookTapped(Book book) { setState(() { _selectedBook = book; }); } class BooksListScreen extends StatelessWidget { final List <Book> books; final ValueChanged<Book> onTapped; BooksListScreen({ @required this .books, @required this .onTapped, }); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(), body: ListView( children: [ for (var book in books) ListTile( title: Text(book.title), subtitle: Text(book.author), onTap: () => onTapped(book), ) ], ), ); } }
由于此应用会有两个页面(一个书单列表也和一个详情的页面),如果选择了某本书(使用 collection if ),则会加入详细页:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 pages: [ MaterialPage( key: ValueKey('BooksListPage' ), child: BooksListScreen( books: books, onTapped: _handleBookTapped, ), ), if (show404) MaterialPage(key: ValueKey('UnknownPage' ), child: UnknownScreen()) else if (_selectedBook != null ) MaterialPage( key: ValueKey(_selectedBook), child: BookDetailsScreen(book: _selectedBook)) ],
注意,这里的 key 会由 book 对象中的值定义作为 MaterialPage
的唯一标识,也就是说,book 对象不同这里的 MaterialPage
就不同。没有唯一的 key,框架就无法确定何时显示不同 Page 之间的过渡动画。
我们还可以继承 Page
来实现自定义行为,例如,在该页面添加了自定义过渡动画:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 class BookDetailsPage extends Page { final Book book; BookDetailsPage({ this .book, }) : super (key: ValueKey(book)); Route createRoute(BuildContext context) { return PageRouteBuilder( settings: this , pageBuilder: (context, animation, animation2) { final tween = Tween(begin: Offset(0.0 , 1.0 ), end: Offset.zero); final curveTween = CurveTween(curve: Curves.easeInOut); return SlideTransition( position: animation.drive(curveTween).drive(tween), child: BookDetailsScreen( key: ValueKey(book), book: book, ), ); }, ); } }
还需要注意的是,只传入 pages 参数而不传入 onPopPage
也会报错,他接受一个回调函数,每次 Navigator.pop()
被调用时就会出发这个函数,我们可以在其中更新路由状态
最后,在pages
不提供onPopPage
回调的情况下提供参数是错误的。每次调用时都会Navigator.pop()
调用此函数。应该使用它来更新状态(修改页面列表),这里我们需要调用 didPop
方法确定是否 pop 成功:
1 2 3 4 5 6 7 8 9 10 11 12 13 onPopPage: (route, result) { if (!route.didPop(result)) { return false ; } setState(() { _selectedBook = null ; }); return true ; },
我们还必须在更新应用程序状态之前检查是否 pop 失败。这里,我们使用了 setState
方法来通知 Flutter 调用 build()
方法,该方法 _selectedBook
为 null 表示展示书单列表页。
完整代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 import 'package:flutter/material.dart' ;void main() { runApp(BooksApp()); } class Book { final String title; final String author; Book(this .title, this .author); } class BooksApp extends StatefulWidget { @override State<StatefulWidget> createState() => _BooksAppState(); } class _BooksAppState extends State <BooksApp > { Book _selectedBook; List <Book> books = [ Book('Stranger in a Strange Land' , 'Robert A. Heinlein' ), Book('Foundation' , 'Isaac Asimov' ), Book('Fahrenheit 451' , 'Ray Bradbury' ), ]; @override Widget build(BuildContext context) { return MaterialApp( title: 'Books App' , home: Navigator( pages: [ MaterialPage( key: ValueKey('BooksListPage' ), child: BooksListScreen( books: books, onTapped: _handleBookTapped, ), ), if (_selectedBook != null ) BookDetailsPage(book: _selectedBook) ], onPopPage: (route, result) { if (!route.didPop(result)) { return false ; } setState(() { _selectedBook = null ; }); return true ; }, ), ); } void _handleBookTapped(Book book) { setState(() { _selectedBook = book; }); } } class BookDetailsPage extends Page { final Book book; BookDetailsPage({ this .book, }) : super (key: ValueKey(book)); Route createRoute(BuildContext context) { return MaterialPageRoute( settings: this , builder: (BuildContext context) { return BookDetailsScreen(book: book); }, ); } } class BooksListScreen extends StatelessWidget { final List <Book> books; final ValueChanged<Book> onTapped; BooksListScreen({ @required this .books, @required this .onTapped, }); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(), body: ListView( children: [ for (var book in books) ListTile( title: Text(book.title), subtitle: Text(book.author), onTap: () => onTapped(book), ) ], ), ); } } class BookDetailsScreen extends StatelessWidget { final Book book; BookDetailsScreen({ @required this .book, }); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(), body: Padding( padding: const EdgeInsets.all(8.0 ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (book != null ) ...[ Text(book.title, style: Theme.of(context).textTheme.headline6), Text(book.author, style: Theme.of(context).textTheme.subtitle1), ], ], ), ), ); } }
目前,我们就实现了声明式的路由管理,单此时我们还无法处理浏览器的后退按钮,也不能同步浏览器地址拦中的链接。
Router 本节,我们来实现通过 RouteInformationParser
, RouterDelegate
更新路由状态,实现与浏览器地址拦中的链接同步
数据类型 首先,我们需要通过 RouteInformationParser
将路由信息解析为指定的数据类型:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 class BookRoutePath { final int id; final bool isUnknown; BookRoutePath.home() : id = null , isUnknown = false ; BookRoutePath.details(this .id) : isUnknown = false ; BookRoutePath.unknown() : id = null , isUnknown = true ; bool get isHomePage => id == null ; bool get isDetailsPage => id != null ; }
在该应用程序中,可以使用 BookRoutePath
类来表示应用程序中的路由路径,我们也可以实现父子类来关系其他各类型的路由信息。
RouterDelegate 接下来,我们实现一个 RouterDelegate
的子类 BookRouterDelegate
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 class BookRouterDelegate extends RouterDelegate <BookRoutePath > with ChangeNotifier , PopNavigatorRouterDelegateMixin <BookRoutePath > { @override Widget build(BuildContext context) { throw UnimplementedError(); } @override GlobalKey<NavigatorState> get navigatorKey => throw UnimplementedError(); @override Future<void > setNewRoutePath(BookRoutePath configuration) { throw UnimplementedError(); } }
BookRouterDelegate
的泛型为 BookRoutePath
,其中包含了决定显示哪个页面所需的所有状态。
此时,我们就可以将 _BooksAppState
中的路由相关的逻辑放到 BookRouterDelegate
中,这里,我们创建了一个 GlobalKey
对象,其他各个状态也都保存在这里面:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 class BookRouterDelegate extends RouterDelegate <BookRoutePath > with ChangeNotifier , PopNavigatorRouterDelegateMixin <BookRoutePath > { final GlobalKey<NavigatorState> navigatorKey; Book _selectedBook; bool show404 = false ; List <Book> books = [ Book('Stranger in a Strange Land' , 'Robert A. Heinlein' ), Book('Foundation' , 'Isaac Asimov' ), Book('Fahrenheit 451' , 'Ray Bradbury' ), ]; BookRouterDelegate() : navigatorKey = GlobalKey<NavigatorState>();
为了能在 URL 中显示正确的路径,我们也需要根据应用程序的当前状态返回一个 BookRoutePath
对象:
1 2 3 4 5 6 7 8 9 10 BookRoutePath get currentConfiguration { if (show404) { return BookRoutePath.unknown(); } return _selectedBook == null ? BookRoutePath.home() : BookRoutePath.details(books.indexOf(_selectedBook)); }
下面,build
方法返回一个 Navigator
组件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 @override Widget build(BuildContext context) { return Navigator( key: navigatorKey, pages: [ MaterialPage( key: ValueKey('BooksListPage' ), child: BooksListScreen( books: books, onTapped: _handleBookTapped, ), ), if (show404) MaterialPage(key: ValueKey('UnknownPage' ), child: UnknownScreen()) else if (_selectedBook != null ) BookDetailsPage(book: _selectedBook) ], onPopPage: (route, result) { if (!route.didPop(result)) { return false ; } _selectedBook = null ; show404 = false ; notifyListeners(); return true ; }, ); }
因为该类并不是组件,而是由 ChangeNotifier
实现,因此这里的 onPopPage
方法需要使用 notifyListeners
替代 setState
来改变状态,当 RouterDelegate
触发状态更新时,Router
同样会触发 RouterDelegate
的 currentConfiguration
方法并调用 build
方法创建出一个新的 Navigator
组件。
_handleBookTapped
方法也需要使用 notifyListeners
代替 setState
:
1 2 3 4 void _handleBookTapped(Book book) { _selectedBook = book; notifyListeners(); }
新页面打开后,Router
会调用setNewRoutePath
方法来更新应用程序的路由状态:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 @override Future<void > setNewRoutePath(BookRoutePath path) async { if (path.isUnknown) { _selectedBook = null ; show404 = true ; return ; } if (path.isDetailsPage) { if (path.id < 0 || path.id > books.length - 1 ) { show404 = true ; return ; } _selectedBook = books[path.id]; } else { _selectedBook = null ; } show404 = false ; }
RouteInformationParser
内部含有一个钩子函数,接受路由信息(RouteInformation
),我们可以在这里将它转换成指定的数据类型(BookRoutePath
)并使用 Uri 解析:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 class BookRouteInformationParser extends RouteInformationParser <BookRoutePath > { @override Future<BookRoutePath> parseRouteInformation( RouteInformation routeInformation) async { final uri = Uri .parse(routeInformation.location); if (uri.pathSegments.length == 0 ) { return BookRoutePath.home(); } if (uri.pathSegments.length == 2 ) { if (uri.pathSegments[0 ] != 'book' ) return BookRoutePath.unknown(); var remaining = uri.pathSegments[1 ]; var id = int .tryParse(remaining); if (id == null ) return BookRoutePath.unknown(); return BookRoutePath.details(id); } return BookRoutePath.unknown(); } @override RouteInformation restoreRouteInformation(BookRoutePath path) { if (path.isUnknown) { return RouteInformation(location: '/404' ); } if (path.isHomePage) { return RouteInformation(location: '/' ); } if (path.isDetailsPage) { return RouteInformation(location: '/book/${path.id} ' ); } return null ; } }
该实现仅针对此应用,并不是常规的路由解析解决方案,具体原理,我们以后再详细了解。最后,要使用这些定义好的类,我们还需要使用全新的 MaterialApp.router
构造函数并传入它们各自的实现:
1 2 3 4 5 return MaterialApp.router( title: 'Books App' , routerDelegate: _routerDelegate, routeInformationParser: _routeInformationParser, );
完整代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 import 'package:flutter/material.dart' ;void main() { runApp(BooksApp()); } class Book { final String title; final String author; Book(this .title, this .author); } class BooksApp extends StatefulWidget { @override State<StatefulWidget> createState() => _BooksAppState(); } class _BooksAppState extends State <BooksApp > { BookRouterDelegate _routerDelegate = BookRouterDelegate(); BookRouteInformationParser _routeInformationParser = BookRouteInformationParser(); @override Widget build(BuildContext context) { return MaterialApp.router( title: 'Books App' , routerDelegate: _routerDelegate, routeInformationParser: _routeInformationParser, ); } } class BookRouteInformationParser extends RouteInformationParser <BookRoutePath > { @override Future<BookRoutePath> parseRouteInformation( RouteInformation routeInformation) async { final uri = Uri .parse(routeInformation.location); if (uri.pathSegments.length == 0 ) { return BookRoutePath.home(); } if (uri.pathSegments.length == 2 ) { if (uri.pathSegments[0 ] != 'book' ) return BookRoutePath.unknown(); var remaining = uri.pathSegments[1 ]; var id = int .tryParse(remaining); if (id == null ) return BookRoutePath.unknown(); return BookRoutePath.details(id); } return BookRoutePath.unknown(); } @override RouteInformation restoreRouteInformation(BookRoutePath path) { if (path.isUnknown) { return RouteInformation(location: '/404' ); } if (path.isHomePage) { return RouteInformation(location: '/' ); } if (path.isDetailsPage) { return RouteInformation(location: '/book/${path.id} ' ); } return null ; } } class BookRouterDelegate extends RouterDelegate <BookRoutePath > with ChangeNotifier , PopNavigatorRouterDelegateMixin <BookRoutePath > { final GlobalKey<NavigatorState> navigatorKey; Book _selectedBook; bool show404 = false ; List <Book> books = [ Book('Stranger in a Strange Land' , 'Robert A. Heinlein' ), Book('Foundation' , 'Isaac Asimov' ), Book('Fahrenheit 451' , 'Ray Bradbury' ), ]; BookRouterDelegate() : navigatorKey = GlobalKey<NavigatorState>(); BookRoutePath get currentConfiguration { if (show404) { return BookRoutePath.unknown(); } return _selectedBook == null ? BookRoutePath.home() : BookRoutePath.details(books.indexOf(_selectedBook)); } @override Widget build(BuildContext context) { return Navigator( key: navigatorKey, pages: [ MaterialPage( key: ValueKey('BooksListPage' ), child: BooksListScreen( books: books, onTapped: _handleBookTapped, ), ), if (show404) MaterialPage(key: ValueKey('UnknownPage' ), child: UnknownScreen()) else if (_selectedBook != null ) BookDetailsPage(book: _selectedBook) ], onPopPage: (route, result) { if (!route.didPop(result)) { return false ; } _selectedBook = null ; show404 = false ; notifyListeners(); return true ; }, ); } @override Future<void > setNewRoutePath(BookRoutePath path) async { if (path.isUnknown) { _selectedBook = null ; show404 = true ; return ; } if (path.isDetailsPage) { if (path.id < 0 || path.id > books.length - 1 ) { show404 = true ; return ; } _selectedBook = books[path.id]; } else { _selectedBook = null ; } show404 = false ; } void _handleBookTapped(Book book) { _selectedBook = book; notifyListeners(); } } class BookDetailsPage extends Page { final Book book; BookDetailsPage({ this .book, }) : super (key: ValueKey(book)); Route createRoute(BuildContext context) { return MaterialPageRoute( settings: this , builder: (BuildContext context) { return BookDetailsScreen(book: book); }, ); } } class BookRoutePath { final int id; final bool isUnknown; BookRoutePath.home() : id = null , isUnknown = false ; BookRoutePath.details(this .id) : isUnknown = false ; BookRoutePath.unknown() : id = null , isUnknown = true ; bool get isHomePage => id == null ; bool get isDetailsPage => id != null ; } class BooksListScreen extends StatelessWidget { final List <Book> books; final ValueChanged<Book> onTapped; BooksListScreen({ @required this .books, @required this .onTapped, }); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(), body: ListView( children: [ for (var book in books) ListTile( title: Text(book.title), subtitle: Text(book.author), onTap: () => onTapped(book), ) ], ), ); } } class BookDetailsScreen extends StatelessWidget { final Book book; BookDetailsScreen({ @required this .book, }); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(), body: Padding( padding: const EdgeInsets.all(8.0 ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (book != null ) ...[ Text(book.title, style: Theme.of(context).textTheme.headline6), Text(book.author, style: Theme.of(context).textTheme.subtitle1), ], ], ), ), ); } } class UnknownScreen extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(), body: Center( child: Text('404!' ), ), ); } }
现在,在 Chrome 中运行该示例就会展示当前访问链接的路由页面,并在手动修改 URL 后也会导航到正确的页面。
TransitionDelegate 我们也可以通过 TransitionDelegate
实现自定义的路由动画。
首先,向 Navigator
传入一个自定义 TransitionDelegate
对象:
1 2 3 4 5 6 7 TransitionDelegate transitionDelegate = NoAnimationTransitionDelegate(); child: Navigator( key: navigatorKey, transitionDelegate: transitionDelegate,
例如,以下实现回关闭所有路由过渡动画:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 class NoAnimationTransitionDelegate extends TransitionDelegate <void > { @override Iterable <RouteTransitionRecord> resolve({ List <RouteTransitionRecord> newPageRouteHistory, Map <RouteTransitionRecord, RouteTransitionRecord> locationToExitingPageRoute, Map <RouteTransitionRecord, List <RouteTransitionRecord>> pageRouteToPagelessRoutes, }) { final results = <RouteTransitionRecord>[]; for (final pageRoute in newPageRouteHistory) { if (pageRoute.isWaitingForEnteringDecision) { pageRoute.markForAdd(); } results.add(pageRoute); } for (final exitingPageRoute in locationToExitingPageRoute.values) { if (exitingPageRoute.isWaitingForExitingDecision) { exitingPageRoute.markForRemove(); final pagelessRoutes = pageRouteToPagelessRoutes[exitingPageRoute]; if (pagelessRoutes != null ) { for (final pagelessRoute in pagelessRoutes) { pagelessRoute.markForRemove(); } } } results.add(exitingPageRoute); } return results; } }
这里,NoAnimationTransitionDelegate
重写了 TransitionDelegate
的 resolve()
方法,该方法可以指定各种路由页面过渡动画是否展示,有如下几个方法:
markForPush
,打开页面时使用过渡动画。
markForAdd
,打开页面时不使用过渡动画。
markForPop
,弹出页面时使用动画,并通知应用,即将该事件传递给 AppRouterDelegate
的 onPopPage
函数。
markForComplete
,弹出页面时不使用过渡动画,同样会通知应用。
markForRemove
,弹出页面时不使用过渡动画,不会通知应用。
该类仅影响最新的声明式 API,因此后退按钮仍显示过渡动画。
大致流程如下:这里遍历了所有声明的页面,包括新页面(newPageRouteHistory
)和已存在的页面(locationToExitingPageRoute
),使用上述几个方法标记它们。在新页面中使用 markForAdd
去除打开的过渡动画,locationToExitingPageRoute
中则使用 markForRemove
不展示过渡动画,并不会通知应用。查看完整示例 。
嵌套路由 我们也提供了嵌套路由的示例,可以实现 BottomAppBar
中为每个 Tab 中的组件实现各自的路由栈,查看具体代码 。
下一步 本文,我们主要探讨了如何针对这个特定的应用使用这些新的路由 API,大家可以以此为基础构建出更高级的实现,在后续的文章中我也会更深入的讨论关于 Navigator 2.0 的更多话题,欢迎关注公众号「MeandNi」。
延伸阅读 https://medium.com/flutter/learning-flutters-new-navigation-and-routing-system-7c9068155ade
https://github.com/flutter/flutter/issues/12146
关注公众号「Meandni」,及时阅读最新前沿技术动态,不至于落后时代。