Riverpod で Navigator 2.0 の画面遷移の実装 1

# 最初に

## この記事について

自分なりに、汎用的に使うことのできる画面遷移を、状態管理アプリのRiverpodと絡めて実装する方法を紹介します。
とりあえず、モバイルで実装のテンプレート的なものがあればというのを目的に書いた記事なので、Webの対応は今回はしません。

## 今回作るもの

![nav__1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/901218/739df86c-977b-7707-e3b8-f150ad7d1a44.gif)

## Riverpodについて

[Riverpod](https://pub.dev/packages/riverpod) は知っている方も多いかと思いますが、かなりメジャーな状態管理用パッケージです。ちなみに、同じくらいメジャーな状態管理パッケージの Provider と同じ作者の方が作成しており、Riverpod はその改良版のような立ち位置にあります。

最近だと[ドキュメント](https://riverpod.dev/ja/docs/getting_started/)が日本語対応されたりしているので、触れたことがない方も始めるいい機会かと思います。

## Navigator 2.0について

Flutterはクロスプラットフォーム開発が売りのフレームワークですが、それに伴ってかなり複雑な構造になり、従来の Navigator では存在した問題を解決するために作られたものが Navigator 2.0 です。

しかし、__実はNavigator 2.0はそのまま扱うにはかなり難しい内容で__、それを理解したつよつよエンジニアが自分のような開発者のために使いやすいパッケージを開発しているのが現状です。

ただ、いつまでもつよつよエンジニアの方に頼ってばかりではいけない！と思い立ち、記事を読んで自分なりに使いやすい方法を見つけました(かなり暫定的なものなので間違っていたり、おすすめのやり方があったらコメントで教えてください！)。

なので、今回はざっくりとした Navigator 2.0 の解説と `Riverpod` を絡めた実践的な実装方法を紹介したいと思います。

# Navigator 2.0 の解説

今回はRiverpodを絡めた実装をメインに紹介するため、詳しくは解説しません。
もっと詳しく知りたい方は以下の記事がおすすめです。

https://medium.com/flutter/learning-flutters-new-navigation-and-routing-system-7c9068155ade

https://zenn.dev/ntaoo/articles/6641e846765da1


## 何が変わったの？

まず従来の Navigator は命令型で 2.0 は状態型という点です。

今までの画面遷移の方法だと`push()`や`pop()`などで命令していました。

```dart
// ↓ HogeScreen をプッシュしろ！
Navigator.of(context).push(MaterialPageRoute(builder: (context) => const HogeScreen()));
```

では 2.0 ではどうなったかというと、

```dart
Navigator(
  // ページのスタックの変化を監視して、変わっていたら画面遷移をする
  pages: [
    const MaterialPage(child: HogeScreen()),
    if (id.isNotEmpty)
      const MaterialPage(child: NextScreen()),
  ],
)
```

このようにスタックを用意しておいて、条件などからスタックの状態を変化させます。その変化を内部で監視して、画面遷移をします。
なので状態型と呼ばれているんですね！

こうすることで何がいいかというと、今までに比べて履歴の管理が簡単になりました。
`popUntil()`とか`pushAndRemoveUntil()`を駆使しないとできない実装に悩まれた経験、皆さんにもあると思います。それももちろん同じように解決できます。

## RouterDelegate

Navigator 2.0を使うには`RouterDelegate`を実装する必要があります。
ここでスタックの状態を管理して画面を遷移させるか判断します。
自分で実装するのはpagesくらいなので、そこまで実装は手間ではないです。
今回はWebに対応させないため、全部の機能を使わないので省略できる部分の説明はしません。

```dart:router_delegate.dart
// 1
class AppRouterDelegate extends RouterDelegate<AppRoute>
    with ChangeNotifier, PopNavigatorRouterDelegateMixin {
  AppRouterDelegate() : navigatorKey = GlobalKey<NavigatorState>();

  // 2
  @override
  final GlobalKey<NavigatorState>? navigatorKey;

  @override
  Widget build(BuildContext context) {
    return Navigator(
      key: navigatorKey,
      // 3
      pages: [
        const MaterialPage(child: HogeScreen()),
      ],
      // 4
      onPopPage: (route, result) {
        return true;
      },
    );
  }

  // 5
  @override
  Future<void> setNewRoutePath(AppRoute configuration) async {}
}
```

1. RouterDelegate の実装には他に `ChangeNotifier` `PopNavigatorRouterDelegateMixin`が必要です。ChangeNotifier は状態の更新用に、 PopNavigatorRouterDelegateMixin は pop 時に処理をすることができる popRoute を実装できるようになります。ただ、今回の実装では状態の管理を Riverpod で行うため、 ChangeNotifier 要素は使いません。また、ジェネリクスを指定していますが、これも今回は使いません。

2. この RouterDelegate で使う NavigatorKey です。コンストラクターで初期化します。

3. ここが主に自分でカスタマイズする部分になります。ユーザーのアクションなどに応じてスタックを変更することで自動的に画面が遷移します。そして、ここには`Page`を継承したWidgetしかおけません。ほとんどのユースケースでは`MaterialPage`で大丈夫です。

4. pop時に渡される値などを処理できます。`true`を返すと常にpop可能になります。`false`を返してあげると pop されなくなります。

5. Web のみ使う機能なので今回は省略します。

また、RouterDelegate の他に`RouteInformationParser`という Web で URL と Flutter 側の画面情報を相互に変換するためのクラスがありますが、今回はモバイルのみなので省略します。

なので、アプリだけを作りたい！という方は上記の知識のみを知っていれば大丈夫です。

# いざ実装

とても簡単なシンプル実装は調べるとよく載っているので割愛して、いきなり Riverpod を絡めた実装になります。

## パッケージ・バージョン

Flutterのバージョンは`2.8.1`です
パッケージは以下の通りです

```yaml:pubspec.yaml
environment:
  sdk: ">=2.12.0 <3.0.0"
  flutter: ">=2.0.0"

dependencies:
  flutter:
    sdk: flutter
  flutter_riverpod: ^1.0.3
```

## RouterDelegate の作成

`RouterDelegate`の枠組みを作っていきます
また、エラー回避のためジェネリクスを指定していますが、使いません。

```dart:AppRouterDelegate
// 今回の実装では AppRouterDelegate がリビルドされる可能性があるためグローバルに宣言
final _navigatorKey = GlobalKey<NavigatorState>();

class AppRouterDelegate extends RouterDelegate<Empty>
    with ChangeNotifier, PopNavigatorRouterDelegateMixin<Empty> {
  @override
  final GlobalKey<NavigatorState>? navigatorKey = _navigatorKey;

  @override
  Widget build(BuildContext context) {
    return Navigator(
      key: navigatorKey,
      pages: [
        // ここにページを置く
      ],
      onPopPage: (route, result) => true,
    );
  }

  @override
  Future<void> setNewRoutePath(Empty configuration) async {}
}

class Empty {}
```

これらを WidgetTree に置くためには`Router()`ウィジェットを使用してください。
使い方は簡単です。
じつはそれ用に`MaterialApp.router`が用意されているのですが、 Web のみで使う parser が必須なので Router で代用します。

```dart:main.dart
class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Router(
        routerDelegate: AppRouterDelegate(), // ここ
      ),
    );
  }
}
```

## Riverpodの導入

### セットアップ

ここでようやく Riverpod の登場です。まずは、Riverpod 用にセットアップします

```dart:main.dart
void main() {
  runApp(
    const ProviderScope(child: MyApp()),
  );
}
```

### ConsumerWidgetを継承

次に`MyApp`に`ConsumerWidget`を継承します。
ついでに`AppRouterDelegate`にWidgetRefを渡します。

```dart:main.dart
class MyApp extends ConsumerWidget { // ここ
  const MyApp({Key? key}) : super(key: key);
  @override
  Widget build(BuildContext context, WidgetRef ref) { // ここ
    return MaterialApp(
      home: Router(
        routerDelegate: AppRouterDelegate(ref), // 渡す
      ),
    );
  }
}

class AppRouterDelegate extends RouterDelegate<Empty>
    with ChangeNotifier, PopNavigatorRouterDelegateMixin<Empty> {
  AppRouterDelegate(this.ref);
  final WidgetRef ref; // 渡される

  ...
}
```

## ページを作成

次にページを作成して、`Navigator`に置きます

```dart:main.dart
class AppRouterDelegate extends RouterDelegate<Empty>
    with ChangeNotifier, PopNavigatorRouterDelegateMixin<Empty> {
  ...

  @override
  Widget build(BuildContext context) {
    return Navigator(
      key: navigatorKey,
      pages: [
        const MaterialPage(child: HogeScreen()),
        const MaterialPage(child: FugaScreen())
      ],
      onPopPage: (route, result) {
        return route.didPop(result);
      },
    );
  }

  ...
}

class HogeScreen extends StatelessWidget {
  const HogeScreen({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Hogeee'),
      ),
    );
  }
}

class FugaScreen extends StatelessWidget {
  const FugaScreen({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Fugaaaaa'),
        backgroundColor: Colors.redAccent, // わかりやすいように色付け
      ),
    );
  }
}
```

## id を StateProvider で作る

ただこれだと常に`FugaScreen()`がスタックの上に来てしまうので遷移ができません。
ここで、`StateProvider`を使って、擬似的なidを作成しましょう。

```dart:id_provider.dart
final idProvider = StateProvider<String>((ref) {
  return '';
});
```

そして、idが空ではない時に`FugaScreen()`に遷移するようにします。

```dart:
class AppRouterDelegate extends RouterDelegate<Empty>
    with ChangeNotifier, PopNavigatorRouterDelegateMixin<Empty> {
  AppRouterDelegate(this.ref);
  final WidgetRef ref;

  @override
  final GlobalKey<NavigatorState>? navigatorKey = _navigatorKey;

  // 1
  String get id => ref.watch(idProvider);
  StateController<String> get provider => ref.read(idProvider.notifier);

  @override
  Widget build(BuildContext context) {
    return Navigator(
      key: navigatorKey,
      pages: [
        const MaterialPage(child: HogeScreen()),
        // 2
        if (id.isNotEmpty) const MaterialPage(child: FugaScreen())
      ],
      onPopPage: (route, result) {
        // 3
        provider.state = '';
        return route.didPop(result);
      },
    );
  }

  ...
}
```

1. 値を監視するために watch で宣言。また、id の変更ように read で provider を宣言。今回はモバイルのみの対応になっているので、watchを使って変更を監視していますが、Webも対応する場合、内部で`ChangeNotifier`を使っているので、以下のように実装した方がいいです。

```dart
  AppRouterDelegate(this.ref) {
    ref.listen<int?>(idProvider, (_, __) => notifyListeners());
  }
```

2. idが空ではない時に`FugaScreen()`を表示

3. 戻る時はスタックを`HogeScreen()`のみにしなければいけないので、idを空にする。

## 遷移用のボタンを追加

また、遷移するために、HogeScreen()にボタンを追加しましょう。

```dart
class HogeScreen extends ConsumerWidget {
  const HogeScreen({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Hogeee'),
      ),
      body: Center(
        child: ElevatedButton(
          child: const Text('次へ'),
          onPressed: () => ref.read(idProvider.notifier).state = 'id',
        ),
      ),
    );
  }
}
```

## 完成🎉

ここまでついてきていますでしょうか？
これで完成です！ビルドしてみましょう！

![nav__1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/901218/739df86c-977b-7707-e3b8-f150ad7d1a44.gif)

きれいにできました！

# まとめ

個人的にはページのスタックを自由にいじれるようになったので、前のNavigatorよりも好印象です
今回の実装も簡単な内容でしたが、かなり拡張性があって、StateProviderだけでなく、StateNotifierProviderとfreezedなんかを使うこともできます。

なので、次回はその実装と、`BottomTab`などを使った`NestedNavigation`などの解説・実装の記事をあげようと思います。

https://twitter.com/tigeeer8/status/1489673224212590592?s=21