创建一个嵌套导航
随着应用程序的发展,它会累积几十甚至上百条路由。虽然,有些路由可以作为顶层(全局)路由。例如,“/”、“profile”、“contact”、“social_feed”
这些都是应用中可能存在的顶层路由。但是,如果你在顶层 Navigator
widget 中定义了所有可能的路由,那么路由列表将会非常庞大,实际上,许多路由更适合嵌套在其他 widget 中处理。
设想一个用于无线灯泡的物联网 (IoT) 设置流程,你可以通过应用程序来控制这个灯泡。该设置流程包括 4 个页面:查找附近的灯泡、选择你要添加的灯泡、添加灯泡、最后完成设置。你可以在顶层 Navigator
widget 中协调这些操作。然而,更合理的做法是,在你的 SetupFlow
widget 中定义一个嵌套的 Navigator
widget,并让这个嵌套的 Navigator
负责管理设置流程中的这 4 个页面。这种导航委托方式有助于加强局部控制,这在软件开发中通常是更可取的。
下面的动画展示了应用程序的行为:
在这个教程中,你将实现一个包含四个页面的物联网 (IoT) 设置流程,该流程在顶层 Navigator
widget 下嵌套了单独管理的导航。
导航准备阶段
#这个物联网 (IoT) 应用程序包含两个顶层页面,以及一个设置流程。将这些路由名称定义为常量,以便在代码中引用它们。
const routeHome = '/';
const routeSettings = '/settings';
const routePrefixDeviceSetup = '/setup/';
const routeDeviceSetupStart = '/setup/$routeDeviceSetupStartPage';
const routeDeviceSetupStartPage = 'find_devices';
const routeDeviceSetupSelectDevicePage = 'select_device';
const routeDeviceSetupConnectingPage = 'connecting';
const routeDeviceSetupFinishedPage = 'finished';
主页和设置页的路由是使用静态名称引用的。然而,设置流程中的页面是通过两个路径组合来生成它们的路由名称的:首先是一个 /setup/
前缀,然后是具体页面的名称。通过将这两个路径组合在一起,你的 Navigator
可以判断出某个路由名称是否属于设置流程,而无需识别所有与设置流程相关的具体页面。
顶层 Navigator
不负责识别具体的设置流程页面。因此,顶层 Navigator
需要解析传入的路由名称,以识别设置流程的前缀。由于需要解析路由名称,不能使用顶层 Navigator
的 routes
属性。相反,你必须为 onGenerateRoute
属性提供一个函数。
实现 onGenerateRoute
函数,以便为三个顶层路径分别返回相应的 widget。
onGenerateRoute: (settings) {
final Widget page;
if (settings.name == routeHome) {
page = const HomeScreen();
} else if (settings.name == routeSettings) {
page = const SettingsScreen();
} else if (settings.name!.startsWith(routePrefixDeviceSetup)) {
final subRoute =
settings.name!.substring(routePrefixDeviceSetup.length);
page = SetupFlow(
setupPageRoute: subRoute,
);
} else {
throw Exception('Unknown route: ${settings.name}');
}
return MaterialPageRoute<dynamic>(
builder: (context) {
return page;
},
settings: settings,
);
},
请注意,主页和设置页的路由是与精确的路由名称相匹配的。然而,设置流程的路由条件只检查前缀。如果路由名称包含设置流程的前缀,那么路由名称的其余部分将被忽略,并将其余部分传递给 SetupFlow
widget 进行处理。这种对路由名称的拆分方式,使顶层 Navigator
可以不关注设置流程中的各个子路由。
创建一个名为 SetupFlow
的 stateful widget,该 widget 接收一个路由名称作为参数。
class SetupFlow extends StatefulWidget {
const SetupFlow({
super.key,
required this.setupPageRoute,
});
final String setupPageRoute;
@override
State<SetupFlow> createState() => SetupFlowState();
}
class SetupFlowState extends State<SetupFlow> {
//...
}
为设置流程显示一个 AppBar
#设置流程显示一个始终可见的 AppBar,贯穿设置流程中的所有页面。
在你的 SetupFlow
widget 的 build()
方法中返回一个 Scaffold
widget,并包含所需的 AppBar
widget。
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: _buildFlowAppBar(),
body: const SizedBox(),
);
}
PreferredSizeWidget _buildFlowAppBar() {
return AppBar(
title: const Text('Bulb Setup'),
);
}
AppBar 显示一个返回箭头,当返回箭头被按下时,会退出设置流程。然而,退出流程会导致用户丢失所有进度。因此,系统会提示用户确认是否真的想要退出设置流程。
提示用户确认是否退出设置流程,并确保在用户按下 Android 设备上的实体返回按钮时也会出现该提示。
Future<void> _onExitPressed() async {
final isConfirmed = await _isExitDesired();
if (isConfirmed && mounted) {
_exitSetup();
}
}
Future<bool> _isExitDesired() async {
return await showDialog<bool>(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Are you sure?'),
content: const Text(
'If you exit device setup, your progress will be lost.'),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop(true);
},
child: const Text('Leave'),
),
TextButton(
onPressed: () {
Navigator.of(context).pop(false);
},
child: const Text('Stay'),
),
],
);
}) ??
false;
}
void _exitSetup() {
Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) {
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) async {
if (didPop) return;
if (await _isExitDesired() && context.mounted) {
_exitSetup();
}
},
child: Scaffold(
appBar: _buildFlowAppBar(),
body: const SizedBox(),
),
);
}
PreferredSizeWidget _buildFlowAppBar() {
return AppBar(
leading: IconButton(
onPressed: _onExitPressed,
icon: const Icon(Icons.chevron_left),
),
title: const Text('Bulb Setup'),
);
}
当用户点击 AppBar 中的返回箭头或按下 Android 设备上的实体返回按钮时,会弹出一个警告对话框,确认用户是否要离开设置流程。如果用户点击 Leave,则设置流程会从顶层导航堆栈中移除。如果用户点击 Stay,则忽略该操作。
你可能会注意到,
Leave 和 Stay 按钮都会调用 Navigator.pop()
。需要明确的是,这个 pop()
操作是将警告对话框从导航堆栈中移除,而不是移除设置流程。
创建嵌套路由
#设置流程的任务是显示流程中相应的页面。
在 SetupFlow
中添加一个 Navigator
widget,并实现 onGenerateRoute
属性。
final _navigatorKey = GlobalKey<NavigatorState>();
void _onDiscoveryComplete() {
_navigatorKey.currentState!.pushNamed(routeDeviceSetupSelectDevicePage);
}
void _onDeviceSelected(String deviceId) {
_navigatorKey.currentState!.pushNamed(routeDeviceSetupConnectingPage);
}
void _onConnectionEstablished() {
_navigatorKey.currentState!.pushNamed(routeDeviceSetupFinishedPage);
}
@override
Widget build(BuildContext context) {
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) async {
if (didPop) return;
if (await _isExitDesired() && context.mounted) {
_exitSetup();
}
},
child: Scaffold(
appBar: _buildFlowAppBar(),
body: Navigator(
key: _navigatorKey,
initialRoute: widget.setupPageRoute,
onGenerateRoute: _onGenerateRoute,
),
),
);
}
Route<Widget> _onGenerateRoute(RouteSettings settings) {
final page = switch (settings.name) {
routeDeviceSetupStartPage => WaitingPage(
message: 'Searching for nearby bulb...',
onWaitComplete: _onDiscoveryComplete,
),
routeDeviceSetupSelectDevicePage => SelectDevicePage(
onDeviceSelected: _onDeviceSelected,
),
routeDeviceSetupConnectingPage => WaitingPage(
message: 'Connecting...',
onWaitComplete: _onConnectionEstablished,
),
routeDeviceSetupFinishedPage => FinishedPage(
onFinishPressed: _exitSetup,
),
_ => throw StateError('Unexpected route name: ${settings.name}!')
};
return MaterialPageRoute(
builder: (context) {
return page;
},
settings: settings,
);
}
_onGenerateRoute
函数的工作方式与顶层 Navigator
相同。该函数接收一个RouteSettings
对象,其中包含了路由名称 name
。根据路由名称,将返回四个流程页面之一。
第一页名为 find_devices
,它会等待几秒钟来模拟网络扫描。在等待时间结束后,页面会调用其回调函数。在这个教程中,回调函数是 _onDiscoveryComplete
。设置流程识别到设备发现完成后,应该显示设备选择页面。因此,在 _onDiscoveryComplete
中,
_navigatorKey
指示嵌套的 Navigator
导航到 select_device
页面。
select_device
页面要求用户从可用设备列表中选择一个设备。在这个教程中,只向用户展示了一个设备。当用户点击设备时,onDeviceSelected
回调被调用。设置流程识别到设备选择后,应该显示连接页面。因此,在 _onDeviceSelected
中,
_navigatorKey
指示嵌套的 Navigator
导航到 "connecting"
页面。
connecting
页面与 find_devices
页面工作方式相同。
connecting
页面等待几秒钟,然后调用其回调函数。在这个教程中,回调函数是 _onConnectionEstablished
。设置流程识别到连接建立后,应该显示最终页面。因此,在 _onConnectionEstablished
中,
_navigatorKey
指示嵌套的 Navigator
导航到 finished
页面。
finished
页面提供了一个 Finish 按钮。当用户点击 Finish 时,_exitSetup
回调被调用,这会将整个设置流程从顶层 Navigator
堆栈中移除,使用户回到主页。
恭喜你!你实现了具有四个子路由的嵌套导航。
交互示例
#运行应用程序:
-
在 Add your first bulb 页面上,点击带有加号 + 的悬浮操作按钮。这会将你带到 Select a nearby device 页面。页面上列出了一个灯泡设备。
-
点击列出的灯泡设备。页面上出现 Finish 按钮。
-
按下 Finish 按钮返回第一页。
import 'package:flutter/material.dart';
const routeHome = '/';
const routeSettings = '/settings';
const routePrefixDeviceSetup = '/setup/';
const routeDeviceSetupStart = '/setup/$routeDeviceSetupStartPage';
const routeDeviceSetupStartPage = 'find_devices';
const routeDeviceSetupSelectDevicePage = 'select_device';
const routeDeviceSetupConnectingPage = 'connecting';
const routeDeviceSetupFinishedPage = 'finished';
void main() {
runApp(
MaterialApp(
theme: ThemeData(
brightness: Brightness.dark,
appBarTheme: const AppBarTheme(
backgroundColor: Colors.blue,
),
floatingActionButtonTheme: const FloatingActionButtonThemeData(
backgroundColor: Colors.blue,
),
),
onGenerateRoute: (settings) {
final Widget page;
if (settings.name == routeHome) {
page = const HomeScreen();
} else if (settings.name == routeSettings) {
page = const SettingsScreen();
} else if (settings.name!.startsWith(routePrefixDeviceSetup)) {
final subRoute =
settings.name!.substring(routePrefixDeviceSetup.length);
page = SetupFlow(
setupPageRoute: subRoute,
);
} else {
throw Exception('Unknown route: ${settings.name}');
}
return MaterialPageRoute<dynamic>(
builder: (context) {
return page;
},
settings: settings,
);
},
debugShowCheckedModeBanner: false,
),
);
}
@immutable
class SetupFlow extends StatefulWidget {
static SetupFlowState of(BuildContext context) {
return context.findAncestorStateOfType<SetupFlowState>()!;
}
const SetupFlow({
super.key,
required this.setupPageRoute,
});
final String setupPageRoute;
@override
SetupFlowState createState() => SetupFlowState();
}
class SetupFlowState extends State<SetupFlow> {
final _navigatorKey = GlobalKey<NavigatorState>();
@override
void initState() {
super.initState();
}
void _onDiscoveryComplete() {
_navigatorKey.currentState!.pushNamed(routeDeviceSetupSelectDevicePage);
}
void _onDeviceSelected(String deviceId) {
_navigatorKey.currentState!.pushNamed(routeDeviceSetupConnectingPage);
}
void _onConnectionEstablished() {
_navigatorKey.currentState!.pushNamed(routeDeviceSetupFinishedPage);
}
Future<void> _onExitPressed() async {
final isConfirmed = await _isExitDesired();
if (isConfirmed && mounted) {
_exitSetup();
}
}
Future<bool> _isExitDesired() async {
return await showDialog<bool>(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Are you sure?'),
content: const Text(
'If you exit device setup, your progress will be lost.'),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop(true);
},
child: const Text('Leave'),
),
TextButton(
onPressed: () {
Navigator.of(context).pop(false);
},
child: const Text('Stay'),
),
],
);
}) ??
false;
}
void _exitSetup() {
Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) {
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) async {
if (didPop) return;
if (await _isExitDesired() && context.mounted) {
_exitSetup();
}
},
child: Scaffold(
appBar: _buildFlowAppBar(),
body: Navigator(
key: _navigatorKey,
initialRoute: widget.setupPageRoute,
onGenerateRoute: _onGenerateRoute,
),
),
);
}
Route<Widget> _onGenerateRoute(RouteSettings settings) {
final page = switch (settings.name) {
routeDeviceSetupStartPage => WaitingPage(
message: 'Searching for nearby bulb...',
onWaitComplete: _onDiscoveryComplete,
),
routeDeviceSetupSelectDevicePage => SelectDevicePage(
onDeviceSelected: _onDeviceSelected,
),
routeDeviceSetupConnectingPage => WaitingPage(
message: 'Connecting...',
onWaitComplete: _onConnectionEstablished,
),
routeDeviceSetupFinishedPage => FinishedPage(
onFinishPressed: _exitSetup,
),
_ => throw StateError('Unexpected route name: ${settings.name}!')
};
return MaterialPageRoute(
builder: (context) {
return page;
},
settings: settings,
);
}
PreferredSizeWidget _buildFlowAppBar() {
return AppBar(
leading: IconButton(
onPressed: _onExitPressed,
icon: const Icon(Icons.chevron_left),
),
title: const Text('Bulb Setup'),
);
}
}
class SelectDevicePage extends StatelessWidget {
const SelectDevicePage({
super.key,
required this.onDeviceSelected,
});
final void Function(String deviceId) onDeviceSelected;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Select a nearby device:',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
height: 54,
child: ElevatedButton(
style: ButtonStyle(
backgroundColor: WidgetStateColor.resolveWith((states) {
return const Color(0xFF222222);
}),
),
onPressed: () {
onDeviceSelected('22n483nk5834');
},
child: const Text(
'Bulb 22n483nk5834',
style: TextStyle(
fontSize: 24,
),
),
),
),
],
),
),
),
);
}
}
class WaitingPage extends StatefulWidget {
const WaitingPage({
super.key,
required this.message,
required this.onWaitComplete,
});
final String message;
final VoidCallback onWaitComplete;
@override
State<WaitingPage> createState() => _WaitingPageState();
}
class _WaitingPageState extends State<WaitingPage> {
@override
void initState() {
super.initState();
_startWaiting();
}
Future<void> _startWaiting() async {
await Future<dynamic>.delayed(const Duration(seconds: 3));
if (mounted) {
widget.onWaitComplete();
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 32),
Text(widget.message),
],
),
),
),
);
}
}
class FinishedPage extends StatelessWidget {
const FinishedPage({
super.key,
required this.onFinishPressed,
});
final VoidCallback onFinishPressed;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 200,
height: 200,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: Color(0xFF222222),
),
child: const Center(
child: Icon(
Icons.lightbulb,
size: 140,
color: Colors.white,
),
),
),
const SizedBox(height: 32),
const Text(
'Bulb added!',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 32),
ElevatedButton(
style: ButtonStyle(
padding: WidgetStateProperty.resolveWith((states) {
return const EdgeInsets.symmetric(
horizontal: 24, vertical: 12);
}),
backgroundColor: WidgetStateColor.resolveWith((states) {
return const Color(0xFF222222);
}),
shape: WidgetStateProperty.resolveWith((states) {
return const StadiumBorder();
}),
),
onPressed: onFinishPressed,
child: const Text(
'Finish',
style: TextStyle(
fontSize: 24,
),
),
),
],
),
),
),
),
);
}
}
@immutable
class HomeScreen extends StatelessWidget {
const HomeScreen({
super.key,
});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: _buildAppBar(context),
body: Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 200,
height: 200,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: Color(0xFF222222),
),
child: Center(
child: Icon(
Icons.lightbulb,
size: 140,
color: Theme.of(context).scaffoldBackgroundColor,
),
),
),
const SizedBox(height: 32),
const Text(
'Add your first bulb',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
Navigator.of(context).pushNamed(routeDeviceSetupStart);
},
child: const Icon(Icons.add),
),
);
}
PreferredSizeWidget _buildAppBar(BuildContext context) {
return AppBar(
title: const Text('Welcome'),
actions: [
IconButton(
icon: const Icon(Icons.settings),
onPressed: () {
Navigator.pushNamed(context, routeSettings);
},
),
],
);
}
}
class SettingsScreen extends StatelessWidget {
const SettingsScreen({
super.key,
});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: _buildAppBar(),
body: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: List.generate(8, (index) {
return Container(
width: double.infinity,
height: 54,
margin: const EdgeInsets.only(left: 16, right: 16, top: 16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: const Color(0xFF222222),
),
);
}),
),
),
);
}
PreferredSizeWidget _buildAppBar() {
return AppBar(
title: const Text('Settings'),
);
}
}
除非另有说明,本文档之所提及适用于 Flutter 的最新稳定版本,本页面最后更新时间: 2024-11-02。 查看文档源码 或者 为本页面内容提出建议。