【Flutter】最小限のコードで理解する「宣言的な画面遷移」と Navigator 2.0
Flutter には 2 種類の画面遷移があります。
1 つが「命令的」な画面遷移で、これは例えば Navigator.push
や Navigator.pop
など Navigator (実際には NavigatorState) が持つメソッドを呼ぶことで 直接的に画面遷移の実行を「命令」する ものです。
もう 1 つが「宣言的」な画面遷移で、Flutter の UI = f(State) の考え方と同じように State を変えてリビルドすることで画面が遷移する というものです。これは Navigator 2.0 という呼称で以前から設計と実装が進められており、 Flutter 1.17 では Page API が、 Flutter 1.22 では Router API が段階的にリリースされています。[1]
おそらくほとんどのアプリ開発者は命令的な画面遷移に慣れているのではないかと思います。 Android の startActivity()
や iOS の performSegue()
、そして Flutter の Navigator.push
など、僕が知る限りアプリ開発で標準で用意されている画面遷移の API は全て命令的であるためです。
しかし、Flutter においては宣言的な UI の構築に命令的な画面遷移がマッチせず、以前から以下のような問題が発生していました。
- 履歴のスタックを自由に編集できない。(
push
による末尾への追加とpop
による末尾からの取り出ししかできない) - Web での URL 直接入力や「戻る」 / 「進む」ボタンに対応できない
- 下タブなど、Navigator がネストする場合に Android などのバックキーがタブ内ではなくアプリ全体に対して処理されてしまう
これらの問題を解決しつつ、 Flutter の「宣言的」な UI 構築に合わせた「宣言的」な画面遷移の実現を目指すのが Navigator 2.0 です。
「命令的」な画面遷移に慣れている多くのアプリ開発者にとって 「宣言的」な画面遷移は考え方を大きく変える必要がある概念 ですので、この記事では、 Navigator 2.0 で導入された Router API を使った最小限のサンプルコードを読みながら「宣言的」な脳で画面遷移する方法を説明していきます。
参考
Navigator 2.0 and Router
Navigator 2.0 の設計とデザインを説明した資料です。 Navigator 2.0 を開発するための構想や考え方、背景についてまとまった資料で、 Page API や Router API の開発者がどのようなことを考えて実装しているのかを理解するためにとても役立つ資料です。
Learning Flutter’s new navigation and routing system
Navigator 2.0 について、公式ドキュメントからリンクが貼られている Medium の記事です。
サンプルコードとともに Page API や Router API の利用方法が説明されていますが、 Web アプリのための記述も含まれるため、特に後半の Router API の説明はだいぶ複雑になっています。(Gist のコメントも「複雑すぎる」の声で溢れています)
後述の通り、この記事のサンプルコードでは Web アプリのための記述をごっそり省くことで、宣言的な画面遷移を実現するための考え方に集中できる作りを目指しています。
Flutter の Navigator 2.0 の解説 前編
上記の参考資料を元に @ntaoo さんによって書かれた日本語の記事です。 Navigator 2.0 の存在を今初めて知った、という方はまずはここから読むと分かりやすいかもしれません。前編では Page API の利用方法までが解説されています。
Navigator 2.0 の解説 後編
上記の記事の後編です。公開されていましたので、追記しました。
後編ではこの記事では大部分を省略 Router API の利用方法を説明しています。
Flutter のソースコード
その他、必要に応じて Navigator
や Page
, Router
といった今回登場するクラスとその周辺のコードを読みながらこの記事を書いています。
Flutter のコードは各クラス、各メソッド、各フィールドのコメントがとても充実していますので、慣れると Web の公式 API リファレンスを読むよりも効率よく理解ができるようになります。オススメです。
備考
この記事のサンプルコードは、あくまで iOS や Android など「モバイルアプリ」で最低限動作するコードになっています。
Flutter で Web アプリを開発する場合は URL の直接入力を処理するために追加の記述が必要ですし、またモバイルアプリの場合でも全て問題なく動作するかどうかは未検証です。
あくまで 「宣言的」な画面遷移のアイデアを理解するため のサンプルコードであるということを理解した上でこの記事を読んでいただければと思います。そのままコピペすればカンペキなアプリが作れるわけではありません。
環境
この記事を書いた時点での各種バージョンは以下の通りです。
$ flutter --version
Flutter 1.22.4 • channel stable • https://github.com/flutter/flutter.git
Framework • revision 1aafb3a8b9 (7 weeks ago) • 2020-11-13 09:59:28 -0800
Engine • revision 2c956a31c0
Tools • Dart 2.10.4
本文
「対応表的」な理解は NG
Navigator 2.0 で画面遷移する際にまず知っておかなければならないのは、 画面遷移のためのメソッドが無い ということです。
命令的な画面遷移における Navigator.push
のようなメソッドはありませんので、例えば「Navigator.push
にあたる Navigator 2.0 のメソッドはこれ!」のように対応表的な理解をすることはできません。前提となる考え方やアプリの設計から変える必要があることをまず理解する必要があります。
しかし、これは必ずしも「全く新しい概念」をゼロから学ばなければならないということではありません。我々はすでに UI = f(State) の考え方を理解しているはずで、画面遷移もそれと同じになるだけだからです。
言い換えると、画面遷移のためのメソッドがないのは、 Text に表示内容を変更するための setText()
メソッドが用意されていないことと同じと言えます。詳しくは後述します。
宣言的な画面遷移のイメージ
ではどのように「宣言的」な画面遷移を実現するのか、従来の「命令的」な画面遷移の考え方と比較しながら確認してみましょう。
命令的な画面遷移の場合
旧来の命令的な画面遷移では、 Navigator
の State である NavigatorState
に対して push
や pop
といったメソッドを呼ぶことで、 NavigatorState が保持する _history
への Route
オブジェクトの追加と、それを元にした画面遷移が実現していました。
例えば、記事一覧画面(ListPage)で記事のリンクをタップしたら記事詳細画面(DetailPage)に遷移するコードは、通常は以下のように書くと思います。(説明しやすい形で改行しています)
onTap: () {
Navigator.of(context) // NavigatorState を取得して
.push(MaterialPageRoute( // 新しいRoute を _history に追加
builder: (context) => DetailPage(id: 'cb09f63a57f0fb'), // 追加した Route は詳細画面を構築する
), // push() の中ではアニメーションしながら詳細画面を表示する処理を実行
);
}
これによって、コードのどこからでも「画面遷移しろ」の命令が呼び出せるような仕組みになっていました。
宣言的な画面遷移の場合
一方で、宣言的な画面遷移ではこのように直接的に「画面遷移する」ためのメソッドを呼び出すことはしません。画面遷移の「状態」を表すデータを用意し、そのデータに基づいて画面遷移のスタック(先ほどの _history
)を構築する、 この状態の時、画面遷移はこうである、という「宣言」 によって画面遷移を表現します。
例えば、同じようにボタンをタップしたら記事詳細画面に遷移するコードは以下のようになります。
onTap: () {
context.read<AppScreenState>(context) // AppScreenState を取得して
.selectedArticleId = 'cb09f63a57f0fb'; // selectedArticleId の値を変更する
}
この例では Provider を使って、自作した状態管理クラスである AppScreenState
が持つ selectedArticleId
に一覧画面で選択された記事の ID をセットしています。
注目したいのは、 onTap
では状態管理クラスの値を変えているだけで、どこにも「画面遷移する」コードが書かれていない点です。
当然これだけでは画面遷移にならないため、この状態の変化に応じて画面遷移するためには以下の通り AppScreenState の値に応じて pages
プロパティの値を変化させる Navigator
を Widget ツリーの祖先(通常は MaterialApp の直下あたり)に差し込まなければなりません。
Widget build(BuildContext context) {
final screenState = context.watch<AppScreenState>(context); // 状態管理オブジェクトを取得
return Navigator(
pages: [
// 一覧ページは常に pages に含まれる
MaterialPage(
key: ValueKey('ListPage'),
child: ListPage(),
),
// 詳細ページは selectedArticle が null でない場合のみ pages に含まれる
if (screenState.selectedArticle != null)
MaterialPage(
key: ValueKey('DetailPage'),
child: DetailPage(
id: screenState.selectedArticle,
),
),
],
);
}
これにより、 Navigator がリビルドされるたびに pages
プロパティのリストが再生成され、リストの中身の変化に応じてアニメーションと共に画面遷移が発生する、というわけです。
例えば前回のビルドでは ListPage しかなかったのに今回のビルドでは ListPage と DetailPage の 2 つがリストに含まれていたら、 アニメーションと共に DetailPage を画面前面に表示する、という具合です。逆に、前回はあった DetailPage が今回のビルドでなくなっていたら、画面上は「戻る」アニメーションが実行されます。
Text の表示文字列を動的に変更するためには?
パッと上記の説明を見ると何だか画面遷移のための手順が増えて面倒な感じがしてきますが、これを理解するための考え方としては先ほどの「Text の表示文字列を動的に変更する」を思い浮かべると良いでしょう。
Flutter では、 Text の内容を変更する時に setText()
のようなメソッドを使わず、
int _count = 0;
Widget build(BuildContext context) {
return Column(
children: <Widget>[
Text('$_count'),
TextButton(
child: const Text(' + 1 '),
onPressed: () {
setState(() => _count += 1);
}
),
],
);
}
のように、 _count
の値を変化した上で setState
でリビルドを発生させることで Widget 全体を構築し直す という考え方で表示内容を変化させています。
画面遷移も同じように、 変化させた値を使って画面遷移の状態全体を構築し直す と考えれば今まで Flutter で身につけた考え方がそのまま使えると言えるのではないでしょうか。
Router API を使ったサンプルコード
では、実際に Navigator 2.0 の API を使った Flutter アプリのサンプルコードを見ながら、宣言的な画面遷移の考え方をより具体的に理解していきましょう。
なお、アプリ全体のソースコードは GitHub に PUSH してあります。必要に応じて、以下のリポジトリも参照してみてください。
MaterialApp.router で Router API を利用する
宣言的な画面遷移を利用するだけであれば、Widget ツリーに差し込んだ Navigator
の pages
プロパティに Page のリストを渡すだけで動作します。しかし、この方法では Android のバックキーを押した時にアプリごと終了してしまうため、通常は Router API を追加で利用して、システムからの画面遷移を引き起こす各種イベントを処理できるようにします。
Router
は Widget ですので、単純に Widget ツリーのどこかに差し込んでも良いのですが、より簡単に、安全に利用する方法として MaterialApp
の代わりとなる MaterialApp.router
が用意されています。
Widget build(BuildContext context) {
return MaterialApp.router(
routeInformationParser: _routeInformationParser,
routerDelegate: _routerDelegate,
);
}
MaterialApp.router
の引数は routeInformationParser
と routerDelegate
の 2 つのプロパティが必須になっています。それぞれの役割は以下の通りです。
routeInformationParser
Web アプリにおいて、 /details/1/edit
のように、各ページを表す URL の文字列をパースし、状態を表すジェネリクスで指定した型のオブジェクトを生成する RouteInformationParser
のサブクラスのオブジェクトを渡します。
iOS や Android など、モバイルアプリの場合は実装が空でも(動作確認した感じ)問題なく動作しますので、この記事では以下の通り継承だけして実装が空のクラスのオブジェクトを渡します。
class VoidRouteInformationParser extends RouteInformationParser<void> {
Future<void> parseRouteInformation(RouteInformation routeInformation) async {}
}
routerDelegate
Navigator
と、 pages
プロパティに渡す Page リストの生成を行う RouterDelegate
のサブクラスを渡します。
MaterialApp.router
は child
や home
といった自分の子 Widget を指定するためのプロパティを持っておらず、 RouterDelegate.build
によって生成された Widget が Widget ツリー上での子となる作りになっています。この RouterDelegate.build
メソッドで先述の Page リストを持った Navigator を返却します。
具体的なコードは、以下に続けて説明します。
RouterDelegate のサブクラスを実装する
では、 routerDelegate
プロパティに渡す RouterDelegate
のサブクラスである ArticleRouterDelegate
を実装します。説明上不要なコードを省略したクラス全体のコードは以下の通りです。
class ArticleRouterDelegate extends RouterDelegate<void>
with ChangeNotifier, PopNavigatorRouterDelegateMixin<void> {
/// Navigator.key に渡すための [GlobalKey]
final GlobalKey<NavigatorState> navigatorKey;
/// 選択された記事ID
/// これが null の場合はトップの一覧画面が、値がある場合はその ID の記事詳細ページが表示される。
int _articleId;
ArticleRouterDelegate() : navigatorKey = GlobalKey<NavigatorState>();
Widget build(BuildContext context) {
return Navigator(
key: navigatorKey,
pages: [
// 一覧ページ(ListPage)は常に表示する
MaterialPage(
key: ValueKey('ListPage'),
child: ListPage(
articles: articles, // 一覧画面で表示するための記事一覧データ
onSelectedArticle: (value) { // 記事が選択された時に呼ばれるコールバック
_articleId = value;
notifyListeners();
},
),
),
// 詳細ページ(DetailPage)は _articleId に値がある場合のみ表示する
if (_articleId != null)
MaterialPage(
key: ValueKey(_articleId),
child: DetailPage(
title: articles[_articleId], // 詳細表示する記事データ
),
),
],
onPopPage: (route, result) {
if (!route.didPop(result)) {
return false;
}
_articleId = null;
notifyListeners();
return true;
},
);
}
Future<void> setNewRoutePath(void configuration) async {
// do nothing
}
}
ここでの重要なコードは以下の部分です。
_articleId
状態管理のデータを保持する int _articleId;
「選択されている記事ID」の状態を保持するための変数です。 StatefulWidget を作成する場合と同様、自身のフィールドに定義し、後述する build()
で参照します。
なお、今回は最低限の実装で説明するために ArticleRouterDelegate 自身のフィールドとして定義しましたが、例えば Provider を使う場合は ChangeNotifier
を mixin した状態管理クラスにこの値を保持し、 context.watch
などで参照するようなコードになると思います。
Navigator.pages を生成する build()
Widget build(BuildContext context) {
return Navigator(
pages: [
.. 省略 ..
]
);
}
Widget.build
や State.build
と同様、 Widget ツリーをビルドするためのメソッドです。ここに、先述の _articleId
を参照して pages
を生成するコードを記述します。
このメソッドは Router のリビルドが発生するたびに呼ばれ、今回のビルドで生成した pages
と前回のビルドで生成した pages
が NavigatorState
の中で比較され、 Page が追加されていれば次の画面へ進む遷移が発生し、逆に減っていれば前の画面へ戻る遷移が発生する、という仕組みになっています。
より厳密には、この pages
プロパティに渡したリスト内1つひとつの Page が持つ Page.createRoute
メソッドによって Route
オブジェクトが作られ[2]、その Route オブジェクトを保持した RouteEntry
オブジェクトが NavigatorState が管理する画面遷移のスタックである _history
に追加されていきます。この _hisotry
の新旧の差分が上記の比較に使われます。
この仕組みを利用することで、従来の命令的な画面遷移ではできなかった「画面遷移の履歴の編集」も簡単に実現できます。
つまり、状態に応じて単純にリストの末尾に Page を追加したりしなかったりするだけではなく、リストの真ん中に任意の Page を差し込んだり、リストの先頭にあった Page をある状態の場合だけ差し込まなかったり、という具合に、 pages
リストの構築方法を自由に記述することで、任意の状態の時に任意の画面遷移を構築することができる、というわけです。
_articleId
を変更する
一覧画面でのユーザー操作に応じて onSelectedArticle: (value) { // 記事が選択された時に呼ばれるコールバック
_articleId = value;
notifyListeners();
},
一覧画面では、ユーザーが記事を選択すると onSelectedArticle
プロパティに渡したコールバックが呼ばれる作りになっています。
先述の通り、このコールバックでやるのは「画面遷移する」ためのメソッドを呼ぶことではなく、 _articleId
を変更して notifyListeners()
を呼び出すことです。これによって「状態を管理するデータを更新して、リビルドする」という Flutter の基本である「宣言的」な画面遷移が実現します。リビルドが発生すれば build()
が再度呼び出され、その時に _articleId
に値が入っていれば DetailPage
を保持した MaterialPage
が(前回と違って) pages
に含まれるようになるからです。
なお、 StatefulWidget では State.setState
によってリビルドを発生させますが、 RouterDelegate は State ではなく ChangeNotifier
を mixin したクラスですので、 notifyListeners()
を呼び出すことでリビルドを発生させています(という挙動になるように Router が実装されています)。
画面が戻ってきたら状態を更新する
onPopPage: (route, result) {
if (!route.didPop(result)) {
return false;
}
_articleId = null
notifyListeners();
return true;
},
バックキーや AppBar の「戻る」アイコンのタップを含む、「前の画面に戻る」遷移の際に共通で呼ばれるコールバックが onPopPgae
です。
宣言的な画面遷移では、「今どのような画面遷移を構築するか」は State (この例では _articleId
)を元に決定されます。
しかし、バックキーや AppBar の戻るボタンタップなどの OS, Flutter 標準の機能で画面を戻ってきた場合、その State もそれに合わせて更新してあげなければ齟齬が出てしまいます(例えば詳細画面から戻ってきたのに _articleId
に値が残ったままの場合、何かのきっかけでリビルドが発生した時にまた詳細画面に遷移してしまいます)。
そのため、引数の route
を使ってどこから戻ってきたかを判定し、例えば「詳細画面から戻ってきた場合は _articleId
を null
にする」のように適切に State の値を変更してあげる必要があるのです。
画面が増えてくればそれだけこの onPopPage
で考慮すべきことは増えてきますが、一方で画面遷移に伴う状態の更新をここで一元管理できるのはメリットであるとも言えます。
setNewRoutePath
Future<void> setNewRoutePath(void configuration) async {
// do nothing
}
setNewRoutePath
は、例えば Web アプリにおいて URL が直接変更された際に呼ばれ、その URL を先述の RouteInformationParser
がパースした結果を引数として受け取るメソッドです。
このメソッドはオーバーライドが必須になっていて、本来であればここでも URL の内容に応じて State を修正するコードを書く必要があるのですが、モバイルアプリにおいては(おそらく)ここでの State の修正が必要になる場面がなさそうですので、一旦空実装としています。
(2021.1.8 追記)
@ntaoo さんよりコメントで、 ディープリンクなど OS からの通知で起動する場合はこれが必要になるとの指摘をいただきました。このあたりも引き続き動作確認しながら調査を進めて、発見があれば追記します。
(OS からの通知というと、PUSH 通知なんかも要検証かもしれません。)
(2021.1.8 追記ここまで)
以上が記事の一覧画面と詳細画面の遷移を実現するためのコードの全容です。実際に動かして確認したい場合は GitHub のサンプルアプリも確認してみてください。(再掲)
まとめ
長々と説明しましたが、結局は 画面遷移の結果構築される履歴のスタックを「Text が表示する文字列を変更するのと同じ」感覚で構築できるようになる 、というのが今回「宣言的な画面遷移」の紹介として説明したかった一番のポイントです。
これを導入することによるメリットは最初に説明した通りで、機能的には Web アプリにおける恩恵が目立つのですが、一方で
- 宣言的な UI 構築と同じ感覚で画面遷移が実現できること
- 画面遷移の状態や「戻る」実行時の処理を一元管理できること
- 予期せぬ場所、予期せぬ順番での画面遷移によってバグが起きるリスクを回避できること
などはモバイルアプリでも得られるメリットであり、これは Flutter が UI = f(State) のアイデアを採用したモチベーションと同じ利点が画面遷移でも得られるということでもあると考えています。
確かに最初は「画面遷移」そのものに対する考え方のアップデートが必要となり、さらには複雑な Router API の使い方の理解も必要となるなど学習コストが高い点は否定できませんが、一度理解して導入してしまえば、 Kotlin / Swift の「命令的」な UI 構築から Flutter の「宣言的」な UI 構築に乗り換えた時と同じ感動が待っているのではないかと思います。[3][4]
-
Issue #45938 を追って読み取れる限り、それぞれこのバージョンで入ったようです。 ↩︎
-
Page の設定にしたがって
Route
を生成する、という点で、 Widget と Element の関係に似ていることが "Navigator 2.0 and Router" にも書かれています。 ↩︎ -
実際、僕はこの「宣言的」な画面遷移の考え方に初めて触れた時、単純に「何だコレすげー!」と思いました。 ↩︎
-
とは言うものの、新しいからと言ってこれを必ず導入しなければならないものでもなく、また学習コストが高いのも事実ですので、実際にこれをアプリに取り入れるか、いつどのように取り入れるかはプロジェクトごとの判断になると思います。 ↩︎
Discussion
モバイルアプリでも、DeepLinkingなどのOSからのIntentを処理するときにこのsetNewRoutePathやRouteInformationParserの実装が必要になるはずだと思います。
確かに DeepLinking ありましたね!
記事修正しつつ、動作確認してみます。ありがとうございます。