创建嵌套导航流
应用程序会随着时间积累数十乃至数百个路由。其中一些路由作为顶层(全局)路由是合理的。例如,`/`、`profile`、`contact`、`social_feed` 都可能是应用内的顶层路由。但是,想象一下如果您在顶层的 `Navigator` widget 中定义了所有可能的路由,那么这个列表会非常长,而且其中许多路由最好由另一个 widget 嵌套处理。
考虑一个物联网 (IoT) 无线灯泡的设置流程,您可以通过应用程序控制它。这个设置流程包含四个页面:
- `find_devices` 页面:查找附近的灯泡。
- `select_device` 页面:选择要添加的灯泡。
- `connecting` 页面:添加灯泡。
- `finished` 页面:完成设置。
您可以从顶层 `Navigator` widget 编排此行为。然而,更合理的方法是在 `SetupFlow` widget 中定义一个第二个嵌套的 `Navigator` widget,并让这个嵌套的 `Navigator` 管理设置流程中的四个页面。这种导航委托有助于实现更好的局部控制,这在软件开发中通常是首选。
以下动画展示了应用程序的行为
在本示例中,您将实现一个四页的 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` 的有状态 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> {
//...
}
为设置流程显示应用栏
#设置流程会显示一个持久性的应用栏,该应用栏在所有页面上都会出现。
从 `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'));
}
应用栏会显示一个返回箭头,当按下返回箭头时,会退出设置流程。然而,退出流程会导致用户丢失所有进度。因此,系统会提示用户确认是否要退出设置流程。
提示用户确认退出设置流程,并确保当用户按下设备上的硬件返回按钮时显示该提示。
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'),
);
}
当用户点击应用栏中的返回箭头或按下设备上的返回按钮时,会弹出一个警报对话框,确认用户是否要离开设置流程。如果用户按下**离开**,则设置流程会从顶层导航堆栈中弹出。如果用户按下**留下**,则该操作被忽略。
您可能会注意到,`Navigator.pop()` 由**离开**和**留下**按钮都调用。需要澄清的是,此 `pop()` 操作是从导航堆栈中弹出警报对话框,而不是设置流程本身。
生成嵌套路由
#设置流程的任务是在流程中显示相应的页面。
将一个 `Navigator` widget 添加到 `SetupFlow`,并实现 `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` 页面为用户提供一个**完成**按钮。当用户点击**完成**时,会调用 `_exitSetup` 回调,这将从顶层 `Navigator` 堆栈中弹出整个设置流程,将用户带回主屏幕。
恭喜!您成功实现了包含四个子路由的嵌套导航。
互动示例
#运行应用
- 在**添加您的第一个灯泡**屏幕上,点击 FAB(显示为加号**+**)。这将带您进入**选择附近的设备**屏幕。列表中显示了一个灯泡。
- 点击列表中显示的灯泡。此时会出现一个**完成!**屏幕。
- 点击**完成**按钮返回第一个屏幕。
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'));
}
}