原文太长,拆成两篇,上一篇是说明,这一篇是练习。
本节将引导您完成使用 Navigator 2.0 API 的练习。我们将最终获得一个可以与 URL 栏保持同步的应用程序,并处理该应用程序和浏览器中的后退按钮按下操作,如以下GIF所示:
要继续学习,请切换到 master channel,使用 Web 支持创建一个新的 Flutter 项目,并将 lib/main.dart
的内容替换为以下内容:
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),
),
);
}
}
Navigator 在其构造函数中有一个新的 pages
参数。如果 Page
对象的列表更改,则 Navigator
将更新路由堆栈以进行匹配。为了了解其工作原理,我们将构建一个显示书籍列表的应用。
在 _BooksAppState
中,保留两种状态:书籍列表和所选书籍:
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
:
@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
):
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
对象的值定义。这告诉 Navigator
,当 Book
对象不同时,此 MaterialPage
对象也与另一个对象不同。没有唯一 key,框架将无法确定何时显示不同 Pages
之间的过渡动画。
注意:如果愿意,还可以扩展 Page
来自定义行为。例如,此页面添加了一个自定义过渡动画:
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()
时,都会调用此函数。它应该用于更新状态(确定页面列表),并且必须在路由上调用 didPop
以确定弹出是否成功:
onPopPage: (route, result) {
if (!route.didPop(result)) {
return false;
}
// Update the list of pages by setting _selectedBook to null
setState(() {
_selectedBook = null;
});
return true;
},
在更新应用程序状态之前,请务必检查 didPop
是否失败。
使用 setState
通知框架调用 build()
方法,当 _selectedBook
为 null
时,该方法返回一个包含单个页面的列表。
这是完整的例子:
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),
],
],
),
),
);
}
}
就目前而言,此应用仅使我们能够以声明方式定义页面堆栈。我们无法处理平台的后退按钮,并且在导航时浏览器的 URL 不会更改。
到目前为止,该应用可以显示不同的页面,但无法处理来自基础平台的路由,例如,如果用户在浏览器中更新了网址,
本节显示如何实现 RouteInformationParser
,RouterDelegate
以及更新应用程序状态。设置完成后,该应用程序将与浏览器的 URL 保持同步。
RouteInformationParser
将路由信息解析为用户定义的数据类型,因此我们首先定义该类型:
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;
}
在此应用程序中,可以使用单个类表示应用程序中的所有路由。相反,您可以选择使用实现超类的不同类,或以其他方式管理路由信息。
接下来,添加一个扩展 RouterDelegate
的类:
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();
}
}
在 RouterDelegate
上定义的通用类型是 BookRoutePath
,它包含决定显示哪些页面所需的所有状态。
我们需要将一些逻辑从 _BooksAppState
移到 BookRouterDelegate
,然后创建一个 GlobalKey
。在此示例中,应用程序状态直接存储在 RouterDelegate
上,但也可以分为另一个类。
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
:
BookRoutePath get currentConfiguration {
if (show404) {
return BookRoutePath.unknown();
}
return _selectedBook == null
? BookRoutePath.home()
: BookRoutePath.details(books.indexOf(_selectedBook));
}
接下来,RouterDelegate
中的 build
方法需要返回一个 Navigator
:
@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;
},
);
}
现在,onPopPage
回调使用 notifyListeners
代替 setState
,因为此类现在是 ChangeNotifier
而不是小部件。当 RouterDelegate
通知其侦听器时,同样会通知 Router
窗口小部件 RouterDelegate
的 currentConfiguration
已更改,并且需要再次调用其构建方法来构建新的 Navigator
。
_handleBookTapped
方法还需要使用 notifyListeners
而不是 setState
:
void _handleBookTapped(Book book) {
_selectedBook = book;
notifyListeners();
}
将新路由推送到应用程序后,Router
调用 setNewRoutePath
,这使我们的应用程序有机会根据对路由的更改来更新应用程序状态:
@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
类进行解析:
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
构造函数并传入我们的自定义实现:
return MaterialApp.router(
title: 'Books App',
routerDelegate: _routerDelegate,
routeInformationParser: _routeInformationParser,
);
这是完整的例子:
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
的自定义实现,该实现可自定义当页面列表更改时路由在屏幕上的显示方式(或从中删除)。如果您需要对此进行自定义,请继续阅读,但是如果您对默认行为感到满意,则可以跳过此部分。
向定义所需行为的 Navigator
提供自定义 TransitionDelegate
:
// New:
TransitionDelegate transitionDelegate = NoAnimationTransitionDelegate();
child: Navigator(
key: navigatorKey,
// New:
transitionDelegate: transitionDelegate,
例如,以下实现禁用所有过渡动画:
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;
}
}
此自定义实现会覆盖 resolve()
,后者负责将各种路由标记为推送,弹出,添加,完成或删除:
markForPush
— 显示带有动画过渡的路由markForAdd
— 显示不带动画过渡的路由markForPop
— 使用动画过渡删除路由,并以结果完成路由。在此上下文中,“正在完成”表示将结果对象传递到 AppRouterDelegate
上的 onPopPage
回调。markForComplete
— 删除没有过渡的路由,并完成 result
markForRemove
— 删除没有动画过渡且未完成的路由。此类仅影响声明性API,这就是为什么后退按钮仍显示过渡动画的原因。
本示例的工作方式:本示例同时查看新路由和退出屏幕的路由。它遍历 newPageRouteHistory
中的所有对象,并使用 markForAdd
标记它们添加而无需过渡动画。接下来,它遍历 locationToExitingPageRoute
映射的值。如果找到标记为 isWaitingForExitingDecision
的路由,则它将调用 markForRemove
以指示应删除该路由而不进行过渡也不要完成。
这个较大的演示演示了如何在另一个 Router
中添加一个 Router
。许多应用程序要求在 BottomAppBar
中具有到达目的地的路由,而在其上方的一堆视图需要路由,这需要两个 Navigator。为此,应用程序使用应用程序状态对象来存储特定于应用程序的导航状态(选定的菜单索引和选定的 Book
对象)。此示例还显示了如何配置哪个 Router
处理后退按钮。
本文探讨了如何针对特定应用程序使用这些 API,但也可以用于构建更高级别的 API 程序包。希望您能与我们一起探索基于这些功能的高级 API 对用户的作用。