Flutter Navigator 2.0 全面解析

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 2.0 推出之前,我们在应用中通常使用下面这两个类来管理各个页面:

  • Navigator,管理一组 Route 对象。
  • Route,一个 Route 通常可以表示一个页面,被 Navigator 持有,常用的两个实现类是 MaterialPageRouteCupertinoPageRoute

Route 可以通过命名路由和组件路由(匿名路由)的方式被 push 进或者 pop 出 Navigator 的路由栈。我们先来简单回顾一下之前的传统用法。

组件路由

传统路由的实现中,Flutter 的各个页面由 Navigator 组织,彼此叠放成一个 “路由栈”,常用的根组件 MaterialAppCupertinoApp 底层就是通过 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) {
// Handle '/'
if (settings.name == '/') {
return MaterialPageRoute(builder: (context) => HomeScreen());
}

// Handle '/details/:id'
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) {
// Handle '/'
if (settings.name == '/') {
return MaterialPageRoute(builder: (context) => HomeScreen());
}

// Handle '/details/:id'
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 提供了一系列全新的接口,可以实现将路由状态成为应用状态的一部分,并能够通过底层 API 实现参数解析的功能,新增的 API 如下:

Navigator 2.0 API 向框架添加了新类,以使应用程序的屏幕成为应用程序状态的功能,并提供解析来自底层平台(如 Web URL)的路由的功能。以下是新功能概述:

  • Page,用来表示 Navigator 路由栈中各个页面的配置信息。

  • Router,用来制定要由 Navigator 展示的页面列表,通常,该页面列表会根据系统或应用程序的状态改变而改变。

  • RouteInformationParser,持有 RouteInformationProvider 提供的 RouteInformation ,可以将其解析为我们定义的数据类型。

  • RouterDelegate,定义应用程序中的路由行为,例如 Router 如何知道应用程序状态的变化以及如何响应。主要的工作就是监听 RouteInformationParser 和应用状态并通过当前页面列表构建 ·。

  • BackButtonDispatcher,响应后退按钮,并通知 Router

下图展示了 RouterDelegateRouterRouteInformationParser 以及用用状态的交互原理,

大致流程如下:

  1. 当系统打开新页面(如 “books / 2”)时,RouteInformationParser 会将其转换为应用中的具体数据类型 T(如 BooksRoutePath)。
  2. 该数据类型会被传递给 RouterDelegatesetNewRoutePath 方法,我们可以在这里更新路由状态(如通过设置 selectedBookId)并调用 notifyListeners 响应该操作。
  3. notifyListeners 会通知 Router 重建 RouterDelegate(通过 build() 方法).
  4. RouterDelegate.build() 返回一个新的 Navigator 实例,并最终展示出我们想要打开的页面(如 selectedBookId)。

下面,我们就来使用 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> {
// New:
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,
),
),
// New:
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;
}

// Update the list of pages by setting _selectedBook to null
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;
}

// Update the list of pages by setting _selectedBook to null
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) {
// TODO
throw UnimplementedError();
}

@override
// TODO
GlobalKey<NavigatorState> get navigatorKey => throw UnimplementedError();

@override
Future<void> setNewRoutePath(BookRoutePath configuration) {
// TODO
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;
}

// Update the list of pages by setting _selectedBook to null
_selectedBook = null;
show404 = false;
notifyListeners();

return true;
},
);
}

因为该类并不是组件,而是由 ChangeNotifier 实现,因此这里的 onPopPage 方法需要使用 notifyListeners 替代 setState 来改变状态,当 RouterDelegate 触发状态更新时,Router 同样会触发 RouterDelegatecurrentConfiguration 方法并调用 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

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);
// Handle '/'
if (uri.pathSegments.length == 0) {
return BookRoutePath.home();
}

// Handle '/book/:id'
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);
}

// Handle unknown routes
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);
// Handle '/'
if (uri.pathSegments.length == 0) {
return BookRoutePath.home();
}

// Handle '/book/:id'
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);
}

// Handle unknown routes
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;
}

// Update the list of pages by setting _selectedBook to null
_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
// New:
TransitionDelegate transitionDelegate = NoAnimationTransitionDelegate();

child: Navigator(
key: navigatorKey,
// New:
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 重写了 TransitionDelegateresolve() 方法,该方法可以指定各种路由页面过渡动画是否展示,有如下几个方法:

  • markForPush ,打开页面时使用过渡动画。

  • markForAdd,打开页面时不使用过渡动画。

  • markForPop,弹出页面时使用动画,并通知应用,即将该事件传递给 AppRouterDelegateonPopPage 函数。

  • 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」,及时阅读最新前沿技术动态,不至于落后时代。

扫一扫,关注「Meandni」
文章作者: Joker
文章链接: https://meandni.com/2020/10/05/flutter-navigator2-and-router/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 Joker's Blog