最初に
この記事について
自分なりに、汎用的に使うことのできる画面遷移を、状態管理アプリのRiverpodと絡めて実装する方法を紹介します。
とりあえず、モバイルで実装のテンプレート的なものがあればというのを目的に書いた記事なので、Webの対応は今回はしません。
今回作るもの
Riverpodについて
Riverpod は知っている方も多いかと思いますが、かなりメジャーな状態管理用パッケージです。ちなみに、同じくらいメジャーな状態管理パッケージの Provider と同じ作者の方が作成しており、Riverpod はその改良版のような立ち位置にあります。
最近だとドキュメントが日本語対応されたりしているので、触れたことがない方も始めるいい機会かと思います。
Navigator 2.0について
Flutterはクロスプラットフォーム開発が売りのフレームワークですが、それに伴ってかなり複雑な構造になり、従来の Navigator では存在した問題を解決するために作られたものが Navigator 2.0 です。
しかし、実はNavigator 2.0はそのまま扱うにはかなり難しい内容で、それを理解したつよつよエンジニアが自分のような開発者のために使いやすいパッケージを開発しているのが現状です。
ただ、いつまでもつよつよエンジニアの方に頼ってばかりではいけない!と思い立ち、記事を読んで自分なりに使いやすい方法を見つけました(かなり暫定的なものなので間違っていたり、おすすめのやり方があったらコメントで教えてください!)。
なので、今回はざっくりとした Navigator 2.0 の解説と Riverpod
を絡めた実践的な実装方法を紹介したいと思います。
Navigator 2.0 の解説
今回はRiverpodを絡めた実装をメインに紹介するため、詳しくは解説しません。
もっと詳しく知りたい方は以下の記事がおすすめです。
何が変わったの?
まず従来の Navigator は命令型で 2.0 は状態型という点です。
今までの画面遷移の方法だとpush()
やpop()
などで命令していました。
// ↓ HogeScreen をプッシュしろ!
Navigator.of(context).push(MaterialPageRoute(builder: (context) => const HogeScreen()));
では 2.0 ではどうなったかというと、
Navigator(
// ページのスタックの変化を監視して、変わっていたら画面遷移をする
pages: [
const MaterialPage(child: HogeScreen()),
if (id.isNotEmpty)
const MaterialPage(child: NextScreen()),
],
)
このようにスタックを用意しておいて、条件などからスタックの状態を変化させます。その変化を内部で監視して、画面遷移をします。
なので状態型と呼ばれているんですね!
こうすることで何がいいかというと、今までに比べて履歴の管理が簡単になりました。
popUntil()
とかpushAndRemoveUntil()
を駆使しないとできない実装に悩まれた経験、皆さんにもあると思います。それももちろん同じように解決できます。
RouterDelegate
Navigator 2.0を使うにはRouterDelegate
を実装する必要があります。
ここでスタックの状態を管理して画面を遷移させるか判断します。
自分で実装するのはpagesくらいなので、そこまで実装は手間ではないです。
今回はWebに対応させないため、全部の機能を使わないので省略できる部分の説明はしません。
// 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 {}
}
-
RouterDelegate の実装には他に
ChangeNotifier
PopNavigatorRouterDelegateMixin
が必要です。ChangeNotifier は状態の更新用に、 PopNavigatorRouterDelegateMixin は pop 時に処理をすることができる popRoute を実装できるようになります。ただ、今回の実装では状態の管理を Riverpod で行うため、 ChangeNotifier 要素は使いません。また、ジェネリクスを指定していますが、これも今回は使いません。 -
この RouterDelegate で使う NavigatorKey です。コンストラクターで初期化します。
-
ここが主に自分でカスタマイズする部分になります。ユーザーのアクションなどに応じてスタックを変更することで自動的に画面が遷移します。そして、ここには
Page
を継承したWidgetしかおけません。ほとんどのユースケースではMaterialPage
で大丈夫です。 -
pop時に渡される値などを処理できます。
true
を返すと常にpop可能になります。false
を返してあげると pop されなくなります。 -
Web のみ使う機能なので今回は省略します。
また、RouterDelegate の他にRouteInformationParser
という Web で URL と Flutter 側の画面情報を相互に変換するためのクラスがありますが、今回はモバイルのみなので省略します。
なので、アプリだけを作りたい!という方は上記の知識のみを知っていれば大丈夫です。
いざ実装
とても簡単なシンプル実装は調べるとよく載っているので割愛して、いきなり Riverpod を絡めた実装になります。
パッケージ・バージョン
Flutterのバージョンは2.8.1
です
パッケージは以下の通りです
environment:
sdk: ">=2.12.0 <3.0.0"
flutter: ">=2.0.0"
dependencies:
flutter:
sdk: flutter
flutter_riverpod: ^1.0.3
RouterDelegate の作成
RouterDelegate
の枠組みを作っていきます
また、エラー回避のためジェネリクスを指定していますが、使いません。
// 今回の実装では 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 で代用します。
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 用にセットアップします
void main() {
runApp(
const ProviderScope(child: MyApp()),
);
}
ConsumerWidgetを継承
次にMyApp
にConsumerWidget
を継承します。
ついでにAppRouterDelegate
にWidgetRefを渡します。
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
に置きます
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を作成しましょう。
final idProvider = StateProvider<String>((ref) {
return '';
});
そして、idが空ではない時にFugaScreen()
に遷移するようにします。
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);
},
);
}
...
}
- 値を監視するために watch で宣言。また、id の変更ように read で provider を宣言。今回はモバイルのみの対応になっているので、watchを使って変更を監視していますが、Webも対応する場合、内部で
ChangeNotifier
を使っているので、以下のように実装した方がいいです。
AppRouterDelegate(this.ref) {
ref.listen<int?>(idProvider, (_, __) => notifyListeners());
}
-
idが空ではない時に
FugaScreen()
を表示 -
戻る時はスタックを
HogeScreen()
のみにしなければいけないので、idを空にする。
遷移用のボタンを追加
また、遷移するために、HogeScreen()にボタンを追加しましょう。
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',
),
),
);
}
}
完成🎉
ここまでついてきていますでしょうか?
これで完成です!ビルドしてみましょう!
きれいにできました!
まとめ
個人的にはページのスタックを自由にいじれるようになったので、前のNavigatorよりも好印象です
今回の実装も簡単な内容でしたが、かなり拡張性があって、StateProviderだけでなく、StateNotifierProviderとfreezedなんかを使うこともできます。
なので、次回はその実装と、BottomTab
などを使ったNestedNavigation
などの解説・実装の記事をあげようと思います。