介绍
在本指南的第 1 部分中,我们将一个简单的 2 屏应用程序迁移到了 Navigator 2.0。为了保持简单,我们做了最低限度的升级,从 Navigator 1.0 升级,只创建一个 RouterDelegate,然后用它代替 Navigator 来发出导航请求。
但是,这种方法错过了 Navigator 2.0 的一些关键优势。例如,能够将地址栏中的 URL 同步到应用程序的当前位置。这也防止用户能够输入特定的 URL 或点击链接到我们应用程序中的特定页面,如下所示。
这显然不是理想的网络体验。因此,我们将更新我们的应用程序,进一步利用 Navigator 2.0 为我们的用户优化网络体验。
这个项目的所有代码都可以在这里找到。我鼓励您下载并继续学习。
升级我们的实施
路由代理
第一步是向我们的 RouterDelegate 添加一些功能。我们将实现 currentConfiguration getter。这是 URL 同步所必需的,默认情况下返回 null。我们将返回当前页面列表的副本。请注意,即使我们的 RouterDelegate 是 List<RouteSettings> 类型,我们也可以返回一个 List<Page>。正如在第 1 部分中讨论的那样,Page 扩展了 RouteSettings,因此效果很好。
@override
List<Page> get currentConfiguration => List.of(_pages);
由于我们将允许用户通过在地址栏中输入 URL 进行导航,因此我们必须考虑不对应于我们应用程序中任何位置的 URL (404)。让我们更新我们的 _createPage 方法以默认返回一个简单的 404 错误页面。
MaterialPage _createPage(RouteSettings routeSettings) {
Widget child;
switch (routeSettings.name) {
case '/':
child = HomePage();
break;
case '/recipe':
child = RecipePage(routeSettings.arguments);
break;
default:
child = Scaffold(
appBar: AppBar(title: Text('404')),
body: Center(child: Text('Page not found')),
);
}
return MaterialPage(
child: child,
key: Key(routeSettings.name),
name: routeSettings.name,
arguments: routeSettings.arguments,
);
}
现在我们将允许用户通过在地址栏中输入 URL 来设置路由路径,我们必须在 RouterDelegates setNewRoutePath 方法中处理它。您可能记得我们需要覆盖它,但在第 1 部分中将其留空。让我们更新它以从 RouteSettings 列表构建我们的 RouterDelegate 的页面列表。我们将使用一个名为 _setPath 的辅助方法来执行此操作。请注意,如果 HomePage 不存在,我们会将其插入到列表的开头。这是由于路径的解析方式。
@override
Future<void> setNewRoutePath(List<RouteSettings> configuration) {
_setPath(configuration
.map((routeSettings) => _createPage(routeSettings)
.toList());
return Future.value(null);
}
void _setPath(List<Page> pages) {
_pages.clear();
_pages.addAll(pages);
if (_pages.first.name != '/')
_pages.insert(0, _createPage(RouteSettings(name: '/')));
notifyListeners();
}
路由信息解析器
现在我们需要扩展 RouteInformationParser。此类负责将 URL 与我们的应用程序同步。在路由器目录中创建文件 information_parser.dart 并定义 MyRouteInformationParser。定义一个常量。构造函数,因为我们可以而且很好。
class MyRouteInformationParser extends
RouteInformationParser<List<RouteSettings>> {
const MyRouteInformationParser() : super();
@override
Future<List<RouteSettings>> parseRouteInformation(
RouteInformation routeInformation) {
// TODO: implement parseRouteInformation
throw UnimplementedError();
}
@override
RouteInformation restoreRouteInformation(configuration) {
// TODO: implement restoreRouteInformation
return super.restoreRouteInformation(configuration);
}
}
parseRouteInformation 帮助我们将 URL 转换为应用位置,restoreRouteInformation 帮助我们将路由位置转换为 URL。
首先,我们覆盖 parseRouteInformation 以将位置解析为 URI。然后我们检查路径段。如果为空,我们将路由到主页。否则,我们将这些段映射到 RouteSettings 列表,将 URL 中的任何查询参数作为参数传递给我们列表中的最后一个 RouteSettings。最后,我们返回 RouteSettingsList 的 Future.value。
@override
Future<List<RouteSettings>> parseRouteInformation(
RouteInformation routeInformation) {
final uri = Uri.parse(routeInformation.location);
if (uri.pathSegments.isEmpty)
return Future.value([RouteSettings(name: '/')]);
final routeSettings = uri.pathSegments
.map((pathSegment) => RouteSettings(
name: '/$pathSegment',
arguments: pathSegment == uri.pathSegments.last
? uri.queryParameters
: null,
))
.toList();
return Future.value(routeSettings);
}
接下来,我们覆盖 restoreRouteInformation 以从我们的 RotuerDelegates 当前配置中识别一个位置。 (还记得我们之前添加的 getter 吗?)然后我们返回一个包含该位置的 RouterInformation 对象。如果我们在接受参数/参数的页面上,我们还将添加一个辅助方法,用于将我们的路由参数转换为 URL 查询参数。我们处理的唯一参数/参数当然是recipe路由上的recipe id。
@override
RouteInformation restoreRouteInformation(List<RouteSettings>
configuration) {
final location = configuration.last.name;
final arguments = _restoreArguments(configuration.last);
return RouteInformation(location: 'arguments');
}
String _restoreArguments(RouteSettings routeSettings) {
if (routeSettings.name != '/recipe') return '';
return '?id=${routeSettings.arguments}';
}
当然,URL 路径结构是一种设计选择,您的可能会有所不同。但是这些方法中应用的原理保持不变。相应地调整。
因为我们的查询参数将采用 Map<String, String> 的形式,所以我们可以从将路由参数更新为相同的形式中受益。我们以前只在屏幕之间传递食谱 ID。如果我们在 parseRouteInformation 中提取 id,这仍然有效,但通过使用 Map,我们实际上将使我们的应用程序更加健壮和面向未来。
首先,我们应该更新 _restoreArguments 方法以从 Map 中提取 id。
return '?id=${(routeSettings.arguments as Map)['id'].toString()}';
现在我们需要更新 RecipePage 的变量名称以反映我们的更改并更新它在 Api.fetchRecipe 调用中的使用。
// RecipePage
final _arguments;
RecipePage(this._arguments);
// _RecipePageState
@override
void initState() {
Api.fetchRecipe(widget._arguments['id'])
.then((cocktail) => setState(() => _cocktail = cocktail));
super.initState();
}
最后,我们更新 _HomePageState 中的 routerDelegate.pushPage 调用以传递一个 Map 作为参数。
onTap: () => routerDelegate.pushPage(
name: '/recipe',
arguments: {
'id': _cocktails[index].id,
},
),
现在转到 MyApp 小部件。更新路由器以使用我们的 RouteInformationParser。
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Navigator 2.0 Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: Router(
routerDelegate: routerDelegate,
routeInformationParser: const MyRouteInformationParser(),
backButtonDispatcher: RootBackButtonDispatcher(),
),
);
}
注意:我们现在可以使用 MaterialApp.router 构造函数。这将允许我们避免定义 backButtonDispatcher 并稍微清理我们的代码。我们之前无法使用它,因为此构造函数需要一个非空的 RouteInformationParser,尽管如果您不需要同步 URL,Router 实际上并不需要一个。让我们更新我们的构建方法以利用它。
@override
Widget build(BuildContext context) {
return MaterialApp.router(
debugShowCheckedModeBanner: false,
title: 'Navigator 2.0 Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
routerDelegate: routerDelegate,
routeInformationParser: const MyRouteInformationParser(),
);
}
处理 404 错误
就像我们对 404 页面请求的考虑一样,我们必须考虑用户输入无效的食谱 ID 作为查询参数。首先,让我们在 Cocktail 类中定义一个 Cocktail.notFound 构造函数。
Cocktail.notFound()
: id = '-1',
name = '404';
现在在我们的 Api.fetchRecipe 方法中,如果我们从 TheCocktailDB API 获得空响应,我们将返回 Cocktail.notFound() 。在当前 return 语句的正上方添加以下行。
if (cocktail == null) return Cocktail.notFound();
最后,我们将更新我们的 _RecipePageState 的构建方法来处理带有 id == '-1' 的鸡尾酒。将以下 appBar 参数添加到 Scaffold。
appBar: _cocktail != null && _cocktail.id == '-1'
? AppBar(
title: Text('404'),
)
: null,
让我们也更新正文以显示未找到食谱。在 _cocktail != null ? 和 CustomScrollView 之间添加以下内容。
_cocktail.id == '-1'
? Center(child: Text('Recipe not found.'))
差不多了
让我们看看到目前为止我们的劳动成果。
那么这里发生了什么? RouterDelegate 告诉 Navigator 何时通过侦听我们的页面列表中的更改来重建。它知道发生变化的唯一方法是我们列表中给定索引处的 Page 的键是否发生变化。回顾我们的 _createPage 方法,我们看到我们将路由名称指定为 MaterialPage 的键。在添加、删除甚至换出页面时,这非常有效。然而,当我们在更新参数时保持相同的页面不变时,Flutter 无法知道它需要用这个新信息重建 Widget。为了解决这个问题,我们将为键分配一个同时考虑名称和参数的值。 RouteSettings 的 String 表示将很好地用于此。
return MaterialPage(
child: child,
key: Key(routeSettings.toString()),
name: routeSettings.name,
arguments: routeSettings.arguments,
);
现在更改地址栏中的 id 查询参数会重建我们的 RecipePage。
好吧,我们的功能现在是正确的,但是每次 id 更改时都对同一页面进行动画处理可能会让我们的用户感到不快和困惑。让我们通过扩展 TransitionDelegate 类来解决这个问题。我们将复制 DefaultTransitionDelegate 并稍微修改它。公平警告,这将是本指南中迄今为止最难掌握的部分,但我们会尽可能顺利地完成它。
过渡委托
首先在路由器目录中创建一个 transition_delegate.dart 文件。在文件中,使用常量构造函数定义 MyTransitionDelegate。
class MyTransitionDelegate extends TransitionDelegate {
const MyTransitionDelegate() : super();
}
接下来,我们必须重写 resolve 方法。下一个代码块直接从 Flutter 的 DefaultTransitionDelegate 复制而来。也可以随意复制和粘贴它。为了简洁起见,我们不会遍历每一行。现在,知道执行以下步骤就足够了。
1.查看所有退出的路线并确定哪些路线(如果有的话)应该动画化,同时考虑无页路线
2.查看所有进入的路线并确定哪些路线(如果有的话)应该在其中设置动画
@override
Iterable<RouteTransitionRecord> resolve({
@required List<RouteTransitionRecord> newPageRouteHistory,
@required Map<RouteTransitionRecord, RouteTransitionRecord> locationToExitingPageRoute,
@required Map<RouteTransitionRecord, List<RouteTransitionRecord>> pageRouteToPagelessRoutes,
}) {
final List<RouteTransitionRecord> results = <RouteTransitionRecord>[];
// This method will handle the exiting route and its corresponding pageless
// route at this location. It will also recursively check if there is any
// other exiting routes above it and handle them accordingly.
void handleExitingRoute(RouteTransitionRecord location, bool isLast) {
final RouteTransitionRecord exitingPageRoute = locationToExitingPageRoute[location];
if (exitingPageRoute == null) return;
if (exitingPageRoute.isWaitingForExitingDecision) {
final bool hasPagelessRoute = pageRouteToPagelessRoutes.containsKey(exitingPageRoute);
final bool isLastExitingPageRoute = isLast && !locationToExitingPageRoute.containsKey(exitingPageRoute);
if (isLastExitingPageRoute && !hasPagelessRoute) {
exitingPageRoute.markForPop(
exitingPageRoute.route.currentResult
);
} else {
exitingPageRoute.markForComplete(
exitingPageRoute.route.currentResult
);
}
if (hasPagelessRoute) {
final List<RouteTransitionRecord> pagelessRoutes = pageRouteToPagelessRoutes[exitingPageRoute];
for (final RouteTransitionRecord pagelessRoute in pagelessRoutes) {
// It is possible that a pageless route that belongs to an exiting
// page-based route does not require exiting decision. This can
// happen if the page list is updated right after a Navigator.pop.
if (pagelessRoute.isWaitingForExitingDecision) {
if (isLastExitingPageRoute && pagelessRoute == pagelessRoutes.last) {
pagelessRoute.markForPop(
pagelessRoute.route.currentResult
);
} else {
pagelessRoute.markForComplete(
pagelessRoute.route.currentResult
);
}
}
}
}
}
results.add(exitingPageRoute);
// It is possible there is another exiting route above this exitingPageRoute.
handleExitingRoute(exitingPageRoute, isLast);
}
// Handles exiting route in the beginning of list.
handleExitingRoute(null, newPageRouteHistory.isEmpty);
for (final RouteTransitionRecord pageRoute in newPageRouteHistory) {
final bool isLastIteration = newPageRouteHistory.last == pageRoute;
if (pageRoute.isWaitingForEnteringDecision) {
if (!locationToExitingPageRoute.containsKey(pageRoute) && isLastIteration) {
pageRoute.markForPush();
} else {
pageRoute.markForAdd();
}
}
results.add(pageRoute);
handleExitingRoute(pageRoute, isLastIteration);
}
return results;
}
我们的目标是如果最上面的退出路由是“/recipe”并且最上面的进入路由也是“/recipe”,则防止进入/退出动画。为此,我们将比较两者的 RouteSettings.name 并将 bool 变量设置为结果。如果没有退出页面,我们可以在不检查的情况下制作动画。在结果声明下方添加以下内容。
final List<RouteTransitionRecord> results = <RouteTransitionRecord>[];
// add this declaration
final bool showAnimation = locationToExitingPageRoute.length == 0 ||
newPageRouteHistory.last.route.settings.name !=
locationToExitingPageRoute.values.last.route.settings.name;
然后我们将简单地在 if 语句中添加一个 showAnimation 检查来确定过渡是否是动画的。我们对无页路由的转换决策过程不做任何事情。
// line 35
// add check for our showAnimation
variable here
if (isLastExitingPageRoute && !hasPagelessRoute && showAnimation) {
exitingPageRoute.markForPop(exitingPageRoute.route.currentResult);
} else {
exitingPageRoute
.markForComplete(exitingPageRoute.route.currentResult);
}
// line 72
// add check for our showAnimation
variable here
if (!locationToExitingPageRoute.containsKey(pageRoute) &&
isLastIteration &&
showAnimation) {
pageRoute.markForPush();
} else {
pageRoute.markForAdd();
}
最后,我们在 RouterDelegate 的构建方法中将自定义的 TransitionDelegate 传递给 Navigator。
@override
Widget build(BuildContext context) {
return Navigator(
key: navigatorKey,
pages: List.of(_pages),
onPopPage: _onPopPage,
transitionDelegate: const MyTransitionDelegate(),
);
}
嗯,这并没有我想象的那么糟糕,是吧?不过,我们已经投入了大量工作。让我们来看看我们的最终产品。
奖励:我在地址栏中草率的输入已经证明我们对 404 错误的处理方法同样有效。耶!
结论
恭喜!您现在知道您完全有能力迁移或设计一个使用 Navigator 2.0 的 Flutter 应用程序,以便在移动、Web 甚至桌面上提供最佳导航体验。让我们回顾一下本指南的这一部分所涵盖的内容。
RouteInformationProvider 是将地址栏中的 URL 同步到应用导航的关键组件。
它使用从 RouterDelegate 返回的 currentConfiguration 来执行此操作。
为了代码的健壮性和更好地处理 URL 查询参数,将 Map 传递给 RouteSettings 的 arguments 变量是个好主意。
我们还学习了如何使用 TransitionDelegate 控制导航动画。
在本系列的第 3 部分也是最后一部分中,我们将看到在我们已经正确实施 Navigator 2.0 后在我们的应用程序中添加深度链接是多么容易。
谢谢阅读!请分享任何意见或问题。
翻译自文章:
https://medium.com/geekculture/a-simpler-guide-to-flutter-navigator-2-0-part-ii-cf294d9dbe
文章中的demo:https://github.com/theLee3/flutter_nav_demo