Flutter WebでNavigator 2.0を使って各ページにURLを設定する

## はじめに
Flutter Webで開発を進めていて、『画面毎のURL設定』というタスクに行き着いた時、ん？これはどうすればいいんだ？となったわけです。
URLを設定するというだけであれば、方法はいくつかあったのですが、

- タブ(BottomNavigationBar, NavigationRail)を含む画面にも対応する
- ブラウザの進む/戻るボタンに対応する

などとなったときに、調べ尽くして行き着いた結論は「Navigator 2.0とやらを使うしかねえなぁ」でした。

## Navigator 2.0を使って簡単な画面を作る

Navigator 2.0とは従来のpush, popによる命令的な画面stack管理から脱却し、アプリの状態に従って宣言的な画面管理を行うために作られた機構です。

まずはNavigator 2.0を使ってログイン画面とホーム画面だけの簡単な画面の遷移を実現してみます。

### 画面状態管理

最初に、アプリの画面状態を管理するためのclassを用意します。

```dart:route_state.dart
class RouteState extends ChangeNotifier {
  bool _isLogin = false;

  bool get isLogin => _isLogin;

  set isLogin(bool flag) {
    _isLogin = true;
    notifyListeners();
  }
}
```

また、画面となる`Auth`と`Home`のclassも用意します(コードは省略)

### RoutePath

次に画面の状態と、その画面が持つURLを定義するclassを用意します。

```dart:sample_route_path.dart
class SampleRoutePath {
  final Uri uri;

  SampleRoutePath.auth() : uri = Uri(path: '/');
  SampleRoutePath.home() : uri = Uri(path: '/home');

  bool get isAuthSection => (uri == SampleRoutePath.auth().uri);
  bool get isHomeSection => (uri == SampleRoutePath.home().uri);
}
```

### RouteInformationParser

次に`RouteInformationParser`をextendsしたclassを用意します。
このclassは`parseRouteInformation()`と`restoreRouteInformation()`のmethodを持っています。

`parseRouteInformation()`はアプリの起動、更新、Webブラウザの進む/戻るボタン、　URL直接入力といったOSからの通知を元に呼び出されます。
引数の`RouteInformation`はURLを持っているため、現在のURLを判定して適切な`SampleRoutePath`を返す処理を実装します。

`restoreRouteInformation()`はアプリ側から呼び出され、`SampleRoutePath`の変更があった際にURLの変更を含んだ`RouteInformation`を構築し、OS側へ通知します。これによってブラウザのロケーションバーのURLを動的に変更することができます。

```dart:sample_route_information_parser.dart
class SampleRouteInformationParser extends RouteInformationParser<SampleRoutePath> {
  @override
  Future<SampleRoutePath> parseRouteInformation(RouteInformation routeInformation) async {
    // uriをチェックし、該当するSampleRoutePathを返す
    final uri = Uri.parse(routeInformation.location ?? '');
    if (uri.pathSegments.length == 1) {
      if (uri.pathSegments[0] == SampleRoutePath.home().uri.pathSegments[0]) {
        return SampleRoutePath.home();
      }
    }
    return SampleRoutePath.auth();
  }

  @override
  RouteInformation? restoreRouteInformation(SampleRoutePath configuration) {
    // configurationをもとに適切なURLを設定し、RouteInformationを構築する
    if (configuration.isAuthSection) {
      return RouteInformation(location: SampleRoutePath.auth().uri.path);
    }
    if (configuration.isHomeSection) {
      return RouteInformation(location: SampleRoutePath.home().uri.path);
    }
    return null;
  }
}
```

### RouterDelegate

次に、`RouterDelegate`をextendsしたclassを用意します。
`RouterDelegate`は`Navigator`をbuildし、画面構成を管理します。
画面状態の変更に合わせて、適切な画面を構築する役割を担っています。

```dart:sample_router_delegate.dart
class SampleRouterDelegate extends RouterDelegate<SampleRoutePath>
    with ChangeNotifier, PopNavigatorRouterDelegateMixin<SampleRoutePath> {
  @override
  final GlobalKey<NavigatorState> navigatorKey;
  final RouteState _routeState;

  SampleRouterDelegate(this._routeState) : navigatorKey = GlobalKey<NavigatorState>() {
    // _routeStateにlistenerを設定することで、状態の変更を検知できるようにしている
    _routeState.addListener(notifyListeners);
  }

  // Routerが自身をリビルドするときにcurrentConfigurationを呼び出し
  // restoreRouteInformation()の引数として渡される
  @override
  SampleRoutePath get currentConfiguration {
    if (_routeState.isLogin) return SampleRoutePath.home();
    return SampleRoutePath.auth();
  }

  @override
  Widget build(BuildContext context) {
    // Navigatorで画面構成を管理する
    return Navigator(
      key: navigatorKey,
      pages: [
        const MaterialPage(
          child: Auth(),
        ),
        if (_routeState.isLogin)
          const MaterialPage(
            child: Home(),
          ),
      ],
      onPopPage: (route, result) {
        return false;
      },
    );
  }

  // OS側からアプリ状態の変更があった際、parseRouteInformation()が行われた後、
  // その結果を引数としてsetNewRoutePathが呼ばれる
  @override
  Future<void> setNewRoutePath(SampleRoutePath configuration) async {
    _routeState.isLogin = configuration.isHomeSection;
  }
}
```

例えばこの例であれば、最初に`Auth`->`Home`の順に画面がstackされている構成となります。
ログインが完了していれば`Home`が、そうでなければ`Auth`が表示されるということです。

### MaterialApp.router

あとは`main`を構築します
`MaterialApp.router`をbuildし、`routeInformationParser`, `routerDelegete`をそれぞれ設定することで、それ以下の画面構成がRouterで管理されます。
状態管理には今回は[provider](https://pub.dev/packages/provider)を利用し、`RouteState`および`SampleRouteDelegate`を管理します。(これでRouteStateをどの画面からも変更でき、なおかつその変更が画面やURLに反映されるようになります)

```dart:main.dart
void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MultiProvider(
      providers: [
        ChangeNotifierProvider(create: (_) => RouteState()),
        ChangeNotifierProvider(create: (context) => SampleRouterDelegate(context.read<RouteState>())),
      ],
      child: _MyAppWidget(),
    );
  }
}

class _MyAppWidget extends StatelessWidget {
  final SampleRouteInformationParser _sampleRouteInformationParser = SampleRouteInformationParser();

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      routeInformationParser: _sampleRouteInformationParser,
      routerDelegate: context.read<SampleRouterDelegate>(),
    );
  }
}
```

これで2つの画面の画面遷移をNavigator 2.0で完全にフォローできました。

![ezgif-7-6aeecbd6bd.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/132823/44dfec0a-a8d0-bab1-401a-dd451488125c.gif)


## Navigator 2.0をタブ切り替え画面に適用させる

では、上記のアプリケーションに追加実装する形で、Home画面をタブ切り替え可能な画面にした場合に、Navigator 2.0をどのように適用すればいいのでしょうか。

おそらく最適解と考えられるのは、Home内に`Router`をもう一つ用意(NestedRouter)し、タブおよびその配下の画面構成をそちらで管理するという方法です。

具体的なコードをベースに見ていきます。以下の仕様を入れ込むことを考えます。
 
- Home画面にタブを持たせてBooks, Info, Settingsの画面を切り替えできるようにする
- Books画面からはボタン押下でBookDetail画面へ遷移

### 画面状態管理の更新

まず画面の変更に伴って、 `RouteState`を見直します。

```dart:route_state.dart
class RouteState extends ChangeNotifier {
  bool _isLogin = false;
  int _homeIndex = 0;
  bool _isBookDetail = false;

  bool get isLogin => _isLogin;
  int get homeIndex => _homeIndex;
  bool get isBookDetail => _isBookDetail;

  void handleLogin() {
    _isLogin = true;
    _homeIndex = 0;
    _isBookDetail = false;
    notifyListeners();
  }

  void handleLogout() {
    _isLogin = false;
    _homeIndex = 0;
    _isBookDetail = false;
    notifyListeners();
  }

  void handleHomeIndex(int index) {
    _isLogin = true;
    _homeIndex = index;
    _isBookDetail = false;
    notifyListeners();
  }

  void handleBookDetail() {
    _isLogin = true;
    _homeIndex = 0;
    _isBookDetail = true;
    notifyListeners();
  }
}
```

### InnerRouterDelegate

内部のRouterとして`SampleInnerRouterDelegate`を用意します。
タブ以降の各画面を`Navigator`に持たせます。

```dart:sample_inner_router_delegate.dart
class SampleInnerRouterDelegate extends RouterDelegate<SampleRoutePath>
    with ChangeNotifier, PopNavigatorRouterDelegateMixin<SampleRoutePath> {
  @override
  final GlobalKey<NavigatorState> navigatorKey;

  SampleInnerRouterDelegate() : navigatorKey = GlobalKey<NavigatorState>();

  @override
  Widget build(BuildContext context) {
    RouteState routeState = context.read<RouteState>();
    // Navigatorをnestさせるとタブ部分に影ができてしまうので、ClipRectで回避している
    return ClipRect(
      child: Navigator(
        key: navigatorKey,
        pages: [
          if (routeState.homeIndex == 0) ...[
            const MaterialPage(child: Books()),
            if (routeState.isBookDetail) const MaterialPage(child: BookDetail()),
          ],
          if (routeState.homeIndex == 1) const MaterialPage(child: Info()),
          if (routeState.homeIndex == 2) const MaterialPage(child: Settings()),
        ],
        onPopPage: (route, result) {
          return false;
        },
      ),
    );
  }

  @override
  Future<void> setNewRoutePath(SampleRoutePath configuration) async {
    // InnerRouteDelegateでは、setNewRoutePathは空実装で問題ない
  }
}
```
(`Books`, `Info`など各画面のコードは省略します)

`SampleInnerRouterDelegate`ではOSとの`RouteInformation`のやりとりを行わないため、 `currencyConfiguration`や`setNewRoutePath()`の実装は不要です。

もちろんその代わり、`SampleRoutePath`, `SampleRouteInformationPerser`, `SampleRouterDelegate`のほうでは新しく追加する各画面に対する設定を追加する必要があります

### RoutePathの更新

```dart:sample_route_path.dart
class SampleRoutePath {
  final Uri uri;

  SampleRoutePath.auth() : uri = Uri(path: '/');
  SampleRoutePath.books() : uri = Uri(path: '/books');
  SampleRoutePath.info() : uri = Uri(path: '/info'); 
  SampleRoutePath.settings() : uri = Uri(path: '/settings');
  SampleRoutePath.bookDetail() : uri = Uri(path: '/books/detail');

  bool get isAuthSection => (uri == SampleRoutePath.auth().uri);
  bool get isBooksSection => (uri == SampleRoutePath.books().uri);
  bool get isInfoSection => (uri == SampleRoutePath.info().uri);
  bool get isSettingsSection => (uri == SampleRoutePath.settings().uri);
  bool get isBookDetailSection => (uri == SampleRoutePath.bookDetail().uri);
}
```

### RouteInformationParserの更新

```dart:sample_route_information_parser.dart
class SampleRouteInformationParser extends RouteInformationParser<SampleRoutePath> {
  @override
  Future<SampleRoutePath> parseRouteInformation(RouteInformation routeInformation) async {
    // uriをチェックし、該当するSampleRoutePathを返す
    final uri = Uri.parse(routeInformation.location ?? '');
    if (uri.pathSegments.length == 1) {
      if (uri.pathSegments[0] == SampleRoutePath.books().uri.pathSegments[0]) {
        return SampleRoutePath.books();
      }
      if (uri.pathSegments[0] == SampleRoutePath.info().uri.pathSegments[0]) {
        return SampleRoutePath.info();
      }
      if (uri.pathSegments[0] == SampleRoutePath.settings().uri.pathSegments[0]) {
        return SampleRoutePath.settings();
      }
    }
    if (uri.pathSegments.length == 2) {
      if (uri.pathSegments[0] == SampleRoutePath.bookDetail().uri.pathSegments[0] &&
          uri.pathSegments[0] == SampleRoutePath.bookDetail().uri.pathSegments[0]) {
        return SampleRoutePath.bookDetail();
      }
    }
    return SampleRoutePath.auth();
  }

  @override
  RouteInformation? restoreRouteInformation(SampleRoutePath configuration) {
    // configurationをもとに適切なURLを設定し、RouteInformationを構築する
    if (configuration.isAuthSection) {
      return RouteInformation(location: SampleRoutePath.auth().uri.path);
    }
    if (configuration.isBooksSection) {
      return RouteInformation(location: SampleRoutePath.books().uri.path);
    }
    if (configuration.isInfoSection) {
      return RouteInformation(location: SampleRoutePath.info().uri.path);
    }
    if (configuration.isSettingsSection) {
      return RouteInformation(location: SampleRoutePath.settings().uri.path);
    }
    if (configuration.isBookDetailSection) {
      return RouteInformation(location: SampleRoutePath.bookDetail().uri.path);
    }
    return null;
  }
}
```

### RouterDelegateの更新

```dart:sample_router_delegate.dart
class SampleRouterDelegate extends RouterDelegate<SampleRoutePath>
    with ChangeNotifier, PopNavigatorRouterDelegateMixin<SampleRoutePath> {

...

  @override
  SampleRoutePath get currentConfiguration {
    if (!_routeState.isLogin) return SampleRoutePath.auth();
    if (_routeState.isBookDetail) return SampleRoutePath.bookDetail();
    switch (_routeState.homeIndex) {
      case 0:
        return SampleRoutePath.books();
      case 1:
        return SampleRoutePath.info();
      case 2:
        return SampleRoutePath.settings();
      default:
        return SampleRoutePath.books();
    }
  }

...

  @override
  Future<void> setNewRoutePath(SampleRoutePath configuration) async {
    if (configuration.isBooksSection) _routeState.handleHomeIndex(0);
    if (configuration.isInfoSection) _routeState.handleHomeIndex(1);
    if (configuration.isSettingsSection) _routeState.handleHomeIndex(2);
    if (configuration.isBookDetailSection) _routeState.handleBookDetail();
    if (configuration.isAuthSection) _routeState.handleLogout();
  }
}
```

### Homeの更新
あとはHomeをタブを持つ形式にし、`SampleInnerRouterDelegate`を引数に持つ`Router`を持たせる。

```dart:home.dart
// SampleInnerRouterDelegateをinitStateでinitializeするために
// HomeをStatefulWidgetに変更した
class Home extends StatefulWidget {
  const Home({Key? key}) : super(key: key);

  @override
  State<Home> createState() => _HomeState();
}

class _HomeState extends State<Home> {
  late SampleInnerRouterDelegate _sampleInnerRouterDelegate;

  @override
  void initState() {
    super.initState();
    _sampleInnerRouterDelegate = SampleInnerRouterDelegate();
  }

  @override
  Widget build(BuildContext context) {
    return Row(
      children: [
        // 今回はTabとしてNavigationRailを採用
        // BottomNavigationBarなどでも同様のことが可能
        NavigationRail(
          selectedIndex: context.watch<RouteState>().homeIndex,
          onDestinationSelected: (int index) => context.read<RouteState>().handleHomeIndex(index),
          destinations: const [
            NavigationRailDestination(icon: Icon(Icons.analytics_rounded), label: Text('Books')),
            NavigationRailDestination(icon: Icon(Icons.accessibility_rounded), label: Text('Info')),
            NavigationRailDestination(icon: Icon(Icons.settings), label: Text('Settings')),
          ],
        ),
        const VerticalDivider(thickness: 1, width: 1),
        Expanded(
          // Routerを持たせ、引数にSampleInnerRouterDelegateを指定する
          child: Router(routerDelegate: _sampleInnerRouterDelegate),
        )
      ],
    );
  }
}
```

これでNavigator 2.0を全ての画面に対して適用することができました。

![ezgif-7-95855df251.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/132823/b141b679-fc4b-351d-fce3-af9945e7c8c5.gif)


## 参考資料

Navigator 2.0については、以下の記事が大変参考になります。
https://zenn.dev/ntaoo/articles/6641e846765da1
https://zenn.dev/ntaoo/articles/e647ceaacb7214

また、タブ切り替えについては以下のStackOverflowの記事をベースに再構築しています。
https://stackoverflow.com/questions/64457071/flutter-web-bottom-navigation-bar-with-url-updating-and-hyperlink-support