跳至主要內容

建立巢狀導覽流程

應用程式隨著時間推移會累積數十甚至數百條路由。您的一些路由適合作為頂層(全域)路由。例如,"/"、"profile"、"contact"、"social_feed" 都是您的應用程式中可能的頂層路由。但是,想像一下,您在頂層的 Navigator 小工具中定義了所有可能的路由。清單會非常長,而且許多這些路由最好在另一個小工具中巢狀處理。

考慮一個用於無線燈泡的物聯網 (IoT) 設定流程,您可以使用應用程式控制該燈泡。此設定流程包含 4 個頁面:尋找附近的燈泡、選擇您要新增的燈泡、新增燈泡,然後完成設定。您可以從頂層的 Navigator 小工具協調此行為。然而,在 SetupFlow 小工具中定義第二個巢狀的 Navigator 小工具,並讓巢狀的 Navigator 擁有設定流程中 4 個頁面的所有權,這樣做更有意義。這種導航委派有助於更大的本地控制,這在開發軟體時通常更可取。

以下動畫顯示應用程式的行為

Gif showing the nested "setup" flow

在此範例中,您將實作一個四頁的物聯網設定流程,該流程在頂層 Navigator 小工具下方維護其自己的巢狀導航。

準備導覽

#

這個物聯網應用程式有兩個頂層畫面,以及設定流程。將這些路由名稱定義為常數,以便可以在程式碼中引用它們。

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';

首頁和設定畫面以靜態名稱引用。然而,設定流程頁面使用兩個路徑來建立其路由名稱:一個 /setup/ 字首,後跟特定頁面的名稱。透過組合這兩個路徑,您的 Navigator 可以確定路由名稱適用於設定流程,而無需識別與設定流程關聯的所有個別頁面。

頂層 Navigator 不負責識別個別的設定流程頁面。因此,您的頂層 Navigator 需要剖析傳入的路由名稱以識別設定流程字首。需要剖析路由名稱表示您無法使用頂層 Navigatorroutes 屬性。相反地,您必須為 onGenerateRoute 屬性提供一個函式。

實作 onGenerateRoute 以針對三個頂層路徑中的每個路徑傳回適當的小工具。

dart
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 小工具進行處理。這種路由名稱的分割使得頂層 Navigator 可以對設定流程中的各種子路由保持不可知。

建立一個名為 SetupFlow 的狀態小工具,該小工具接受一個路由名稱。

dart
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 小工具的 build() 方法傳回一個 Scaffold 小工具,並包含所需的 AppBar 小工具。

dart
@override
Widget build(BuildContext context) {
  return Scaffold(
    appBar: _buildFlowAppBar(),
    body: const SizedBox(),
  );
}

PreferredSizeWidget _buildFlowAppBar() {
  return AppBar(
    title: const Text('Bulb Setup'),
  );
}

當按下返回箭頭時,應用程式列會顯示一個返回箭頭並退出設定流程。但是,退出流程會導致使用者失去所有進度。因此,系統會提示使用者確認是否要退出設定流程。

提示使用者確認退出設定流程,並確保在使用者按下 Android 上的硬體返回按鈕時會出現提示。

dart
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'),
  );
}

當使用者點擊應用程式列中的返回箭頭,或按下 Android 上的返回按鈕時,會彈出一個警示對話方塊,以確認使用者是否要離開設定流程。如果使用者按下離開,則設定流程會將自己從頂層導航堆疊中彈出。如果使用者按下留下,則會忽略該動作。

您可能會注意到 離開留下 按鈕都會調用 Navigator.pop()。明確來說,這個 pop() 動作會將警示對話方塊從導航堆疊中彈出,而不是設定流程。

產生巢狀路由

#

設定流程的工作是在流程中顯示適當的頁面。

將一個 Navigator 小工具新增到 SetupFlow,並實作 onGenerateRoute 屬性。

dart
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'),
    );
  }
}