【Flutter】Navigator 2.0 を Riverpod+Hooks+freezed パターンで画面遷移してみた

Navigator 2.0 がリリースされてからしばらく立ちますが、あまり記事も多くがなかったので、実際にアプリを作りながら勉強した内容を記事にしたいと思います。

Navigator 2.0 は、これまでの「push」や「pop」と言った命令的な画面遷移をさせるのではなく、フラグなどの状態変化をきっかけにして、予め定義した通りに自動で遷移させる、いわゆる「宣言的」な画面遷移を実現しています。

正直、これまでと概念が全く異なりとっつきにくい印象なので、Flutter 初学者は面食らうかもしれません。

「論より証拠」と言うことで作成したサンプルプログラムを紹介します。

【Flutter】Navigator 2.0 を Riverpod+Hooks+freezed パターンで画面遷移してみた

今回は表題の通り昨今のトレンドの一つであるRiverpod+Hooks+freezedパターンで実装してみました。

画面構成

ちょっと実践的なことを意識してボトムナビゲーションがある画面をホーム画面として2階層までの画面遷移を行っています。

ざっくり下記のような画面構成にしています。

  • AppMainPage:ボトムナビゲーション
    • MainPage1:ボトムナビゲーションの第一画面
      • SubPage1:MainPage1から遷移する画面
    • MainPage2:ボトムナビゲーションの第二画面
      • SubPage2:MainPage2から遷移する画面
      • SubPage3:SubPage2から遷移する画面

画面定義

AppPage

各画面の定義を enum で行います。

extension で画面名の取得と、画面名からenum定義を返すメソッドを定義しています。

enum AppPage {
  appMain,
  main1,
  main2,
  sub1,
  sub2,
  sub3,
}

extension AppPageExt on AppPage {
  String get name => _names[this]!;

  static AppPage? fromName(String name) {
    if (name == AppPage.appMain.name) return AppPage.appMain;
    if (name == AppPage.main1.name) return AppPage.main1;
    if (name == AppPage.main2.name) return AppPage.main2;
    if (name == AppPage.sub1.name) return AppPage.sub1;
    if (name == AppPage.sub2.name) return AppPage.sub2;
    if (name == AppPage.sub3.name) return AppPage.sub3;
    return null;
  }

  static final _names = {
    AppPage.appMain: 'appMain',
    AppPage.main1: 'main1',
    AppPage.main2: 'main2',
    AppPage.sub1: 'sub1',
    AppPage.sub2: 'sub2',
    AppPage.sub3: 'sub3',
  };
}

BottomNavigationPage

ボトムナビゲーションにセットする画面の定義です。

extension でタイトルとインデックスを取得できるようにしています。

enum BottomNavigationPage {
  main1,
  main2,
}

extension BottomNavigationPageExt on BottomNavigationPage {
  int get index => _indexes[this]!;

  String get appBarTiltle => _appBarTitles[this]!;

  static BottomNavigationPage? fromIndex(int index) {
    switch (index) {
      case 0:
        return BottomNavigationPage.main1;
      case 1:
        return BottomNavigationPage.main2;
      default:
        return null;
    }
  }

  static final _indexes = {
    BottomNavigationPage.main1: 0,
    BottomNavigationPage.main2: 1,
  };

  static final _appBarTitles = {
    BottomNavigationPage.main1: 'メインページ1',
    BottomNavigationPage.main2: 'メインページ2',
  };
}

画面遷移用の State・Provider・Notifierの定義

Riverpod+Hooks+freezed パターンで画面遷移するため AppStateAppNotifier の2つの Class と、グローバルに appProvider を宣言します。

パッケージのインストール

予め pubspec.yaml で以下のパッケージを取り込んでおきます。バージョンは執筆時のものであるため、最新のバージョンをお薦めします。

dependencies:
・
・
  riverpod: ^1.0.3
  flutter_hooks: ^0.18.5+1
  hooks_riverpod: ^1.0.4
  freezed_annotation: ^2.1.0
・
・
dev_dependencies:
・
・
  build_runner: ^2.2.0
  freezed: ^2.1.0+1

AppState クラス

ボトムナビゲーション遷移と階層遷移の状態を管理するフラグを宣言しています。

mport 'package:freezed_annotation/freezed_annotation.dart';
import 'package:test00_navigation2/bottom_navigation_page.dart';

part 'app_state.freezed.dart';

@freezed
class AppState with _$AppState {
  const factory AppState({
    @Default(BottomNavigationPage.main1) BottomNavigationPage page,
    @Default(false) bool isShowSub1,
    @Default(false) bool isShowSub2,
    @Default(false) bool isShowSub3,
  }) = _AppState;
}

また、ターミナルから build_runner で app_state.freezed.dart を出力してください。

flutter pub run build_runner build --delete-conflicting-outputs

AppNotifier クラス

ボトムナビゲーション遷移や階層遷移の状態を変化させるメソッドを定義しています。

import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:test00_navigation2/app_state.dart';
import 'package:test00_navigation2/bottom_navigation_page.dart';

class AppNotifier extends StateNotifier<AppState> {
  AppNotifier() : super(const AppState());

  void changeBottomNavigation(int index) {
    final page = BottomNavigationPageExt.fromIndex(index);
    if (page != null) {
      state = state.copyWith(page: page);
    }
  }

  void showSub1() {
    state = state.copyWith(isShowSub1: true);
  }

  void showSub2() {
    state = state.copyWith(isShowSub2: true);
  }

  void showSub3() {
    state = state.copyWith(isShowSub3: true);
  }

  void didPopSub1() {
    state = state.copyWith(isShowSub1: false);
  }

  void didPopSub2() {
    state = state.copyWith(isShowSub2: false);
  }

  void didPopSub3() {
    state = state.copyWith(isShowSub3: false);
  }

  void popToMain2() {
    state = state.copyWith(
      isShowSub2: false,
      isShowSub3: false,
    );
  }
}

appProvider 変数

アプケーション全体で利用するので、状態維持のため autoDispose は付加しません

import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:test00_navigation2/app_notifier.dart';
import 'package:test00_navigation2/app_state.dart';

final appProvider = StateNotifierProvider<AppNotifier, AppState>(
  (ref) => AppNotifier(),
);

各ページの定義

全てのページで、自身を生成する静的なメソッド create() を定義しています。

これを後述する Navigator にセットして画面遷移を宣言します。

AppMainPage

ボトムナビゲーションがあるホームのページです。

import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:test00_navigation2/app_page.dart';
import 'package:test00_navigation2/app_provider.dart';
import 'package:test00_navigation2/bottom_navigation_page.dart';
import 'package:test00_navigation2/main_page1.dart';
import 'package:test00_navigation2/main_page2.dart';

class AppMainPage extends HookConsumerWidget {
  const AppMainPage({Key? key}) : super(key: key);

  static MaterialPage create() {
    return MaterialPage(
      name: AppPage.appMain.name,
      key: ValueKey(AppPage.appMain.name),
      child: const AppMainPage(),
    );
  }

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final state = ref.watch(appProvider);
    return Scaffold(
      appBar: AppBar(
        title: Text(state.page.appBarTiltle),
        automaticallyImplyLeading: false,
      ),
      body: _buildBody(context, ref),
      bottomNavigationBar: BottomNavigationBar(
        currentIndex: state.page.index,
        items: const [
          BottomNavigationBarItem(icon: Icon(Icons.list), label: 'メイン1'),
          BottomNavigationBarItem(icon: Icon(Icons.person), label: 'メイン2'),
        ],
        onTap: (index) => ref.read(appProvider.notifier).changeBottomNavigation(index),
        type: BottomNavigationBarType.fixed,
      ),
    );
  }

  Widget _buildBody(BuildContext context, WidgetRef ref) {
    final state = ref.watch(appProvider);
    switch (state.page) {
      case BottomNavigationPage.main1:
        return const MainPage1();
      case BottomNavigationPage.main2:
        return const MainPage2();
    }
  }
}

MainPage1

ボトムナビゲーションの1つ目のページです。

ボタン押下で showSub1() をコールして SubPage1 へ遷移します。

import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:test00_navigation2/app_page.dart';
import 'package:test00_navigation2/app_provider.dart';

class MainPage1 extends HookConsumerWidget {
  const MainPage1({Key? key}) : super(key: key);

  static MaterialPage create() {
    return MaterialPage(
      name: AppPage.main1.name,
      key: ValueKey(AppPage.main1.name),
      child: const MainPage1(),
    );
  }

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return Scaffold(
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Text('メインページ1'),
            TextButton(
              onPressed: () => ref.read(appProvider.notifier).showSub1(),
              child: const Text('サブページ1へ'),
            )
          ],
        ),
      ),
    );
  }
}

MainPage2

MainPage1 と同様に SubPage2 へ遷移できます。

import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:test00_navigation2/app_page.dart';
import 'package:test00_navigation2/app_provider.dart';

class MainPage2 extends HookConsumerWidget {
  const MainPage2({Key? key}) : super(key: key);

  static MaterialPage create() {
    return MaterialPage(
      name: AppPage.main2.name,
      key: ValueKey(AppPage.main2.name),
      child: const MainPage2(),
    );
  }

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return Scaffold(
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Text('メインページ2'),
            TextButton(
              onPressed: () => ref.read(appProvider.notifier).showSub2(),
              child: const Text('サブページ2へ'),
            )
          ],
        ),
      ),
    );
  }
}

SubPage1

遷移先は無いため特に言及はありません。

import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:test00_navigation2/app_page.dart';

class SubPage1 extends HookConsumerWidget {
  const SubPage1({Key? key}) : super(key: key);

  static MaterialPage create() {
    return MaterialPage(
      name: AppPage.sub1.name,
      key: ValueKey(AppPage.sub1.name),
      child: const SubPage1(),
    );
  }

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return Scaffold(
      appBar: AppBar(title: const Text('サブページ1')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: const [
            Text('サブページ1'),
          ],
        ),
      ),
    );
  }
}

SubPage2

SubPage3 へ遷移できる以外は特に何もありません。

import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:test00_navigation2/app_page.dart';
import 'package:test00_navigation2/app_provider.dart';

class SubPage2 extends HookConsumerWidget {
  const SubPage2({Key? key}) : super(key: key);

  static MaterialPage create() {
    return MaterialPage(
      name: AppPage.sub2.name,
      key: ValueKey(AppPage.sub2.name),
      child: const SubPage2(),
    );
  }

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return Scaffold(
      appBar: AppBar(title: const Text('サブページ2')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Text('サブページ2'),
            TextButton(
              onPressed: () => ref.read(appProvider.notifier).showSub3(),
              child: const Text('サブページ3へ'),
            )
          ],
        ),
      ),
    );
  }
}

SubPage3

遷移先のページはありません。

今回は遷移元の SubPage2 をすっ飛ばして、一気に MainPage2 まで戻るため、ボタン押下で popToMain2() メソッドをコールしています。

popToRoot 的な複数画面前に戻る時はこのようなやり方で実現できるはずです。

import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:test00_navigation2/app_page.dart';
import 'package:test00_navigation2/app_provider.dart';

class SubPage3 extends HookConsumerWidget {
  const SubPage3({Key? key}) : super(key: key);

  static MaterialPage create() {
    return MaterialPage(
      name: AppPage.sub3.name,
      key: ValueKey(AppPage.sub3.name),
      child: const SubPage3(),
    );
  }

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return Scaffold(
      appBar: AppBar(title: const Text('サブページ3')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Text('サブページ3'),
            TextButton(
              onPressed: () => ref.read(appProvider.notifier).popToMain2(),
              child: const Text('メインページ2まで戻る'),
            )
          ],
        ),
      ),
    );
  }
}

ナビゲーション定義

前置きが長くなりましたが、いよいよ本題の Navigator 2.0 の画面遷移の宣言です。

宣言的にナビゲーションを定義するには MateriapApphome プロパティに Navigator と言う Widget をセットします。

そして、Navigator の page プロパティに表示したいページを配列で宣言します。

また、onPopPage プロパティでは、OSの戻るボタンを押下(スワイプ等の戻る操作も含む)した時のハンドリングを行っています。

import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:test00_navigation2/app_main_page.dart';
import 'package:test00_navigation2/app_page.dart';
import 'package:test00_navigation2/app_provider.dart';
import 'package:test00_navigation2/bottom_navigation_page.dart';
import 'package:test00_navigation2/sub_page1.dart';
import 'package:test00_navigation2/sub_page2.dart';
import 'package:test00_navigation2/sub_page3.dart';

void main() {
  runApp(const ProviderScope(child: MyApp()));
}

class MyApp extends HookConsumerWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return MaterialApp(
      title: 'Navigation 2.0',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: Navigator(
        pages: _createPages(ref),
        onPopPage: (route, result) => _handlePopPage(route, result, ref),
      ),
    );
  }

  List<Page> _createPages(WidgetRef ref) {
    var pages = <Page>[];

    pages.add(AppMainPage.create());

    switch (ref.watch(appProvider).page) {
      case BottomNavigationPage.main1:
        if (ref.watch(appProvider).isShowSub1) {
          pages.add(SubPage1.create());
        }
        break;
      case BottomNavigationPage.main2:
        if (ref.watch(appProvider).isShowSub2) {
          pages.add(SubPage2.create());
          if (ref.watch(appProvider).isShowSub3) {
            pages.add(SubPage3.create());
          }
        }
        break;
    }

    return pages;
  }

  bool _handlePopPage(
    Route<dynamic> route,
    dynamic result,
    WidgetRef ref,
  ) {
    if (!route.didPop(result)) {
      return false;
    }

    final name = route.settings.name ?? '';
    final page = AppPageExt.fromName(name);
    final notifier = ref.read(appProvider.notifier);

    switch (page) {
      case AppPage.sub1:
        notifier.didPopSub1();
        break;
      case AppPage.sub2:
        notifier.didPopSub2();
        break;
      case AppPage.sub3:
        notifier.didPopSub3();
        break;
      default:
        break;
    }

    return true;
  }
}

_createPages

_createPages メソッドでは画面の配列を作成しています。

AppMainPage はホーム画面なので無条件に add します。

pages.add(AppMainPage.create());

MainPage1 と MainPage2 は BottomNavigation 配下にセットされているのでここでは登場しません。

SubPage1〜SubPage3 については、AppState のフラグを見て pages に add するか判断します

switch (ref.watch(appProvider).page) {
  case BottomNavigationPage.main1:
    if (ref.watch(appProvider).isShowSub1) {
      pages.add(SubPage1.create());
    }
    break;
  case BottomNavigationPage.main2:
    if (ref.watch(appProvider).isShowSub2) {
      pages.add(SubPage2.create());
      if (ref.watch(appProvider).isShowSub3) {
        pages.add(SubPage3.create());
      }
    }
    break;
}

_handlePopPage

_handlePopPage では戻るボタンを押下したときのハンドリングを行います。

戻る画面がない時は以下のように false を返す必要があります。

if (!route.didPop(result)) {
  return false;
}

画面遷移の際に showSub1() 等でフラグを true にしていますが、戻るボタンを押下しただけでは当然 false になりません。false にしないと戻った途端また遷移してしまいます

onPopPage には Route<dynamic> route と言う引数があり、そこから戻ろうとしている(戻るボタンを押した)画面を判定することができます。

route.settings.name から AppPageExt.fromName でページを特定し、各ページ遷移に応じたフラグを false に戻るメソッドを呼び出します。

final name = route.settings.name ?? '';
final page = AppPageExt.fromName(name);
final notifier = ref.read(appProvider.notifier);

switch (page) {
  case AppPage.sub1:
    notifier.didPopSub1();
    break;
  case AppPage.sub2:
    notifier.didPopSub2();
    break;
  case AppPage.sub3:
    notifier.didPopSub3();
    break;
  default:
    break;
}

以上で冒頭のGif動画の画面遷移を行うことができるようになります。

今回は Riverpod を意識したため、画面遷移条件のフラグを StateNotifier を使って行いましたが、シングルトンのクラスで管理するような方法でも良いかもしれません。

また、引数を渡す方法、DynamicLinks 等からの画面遷移については触れていませんので今後検証して追記したいと思います。

その他、記事全般について指摘事項などありましたらコメント頂けると嬉しいです。