Flutter の Navigator 2.0 の解説 前編
Flutter に新しく実装された Navigator 2.0 の解説を試みます。内容としては、
Learning Flutter’s new navigation and routing system,
Navigator 2.0 and Router (PUBLICLY SHARED) - Google Docs, そして Navigator 2.0 に関する 複数の GitHub issue の議論の内容をまとめたものになります。さらに、その議論をもとに AuthGuard などの機能を実装した独自のサンプルコードを添付しています。ただし、ナイーブに実装した段階のものであるため、うまく構造化されておらず、そのままではプロダクションでの使用に耐えません。
解説量が長くなってしまい、また期日を過ぎてしまったので、前後編に分けています。独自のサンプルコードも後編で添付します。
現状
Navigator 2.0 は、従来の Navigator (1.0) のさまざまな課題を解消するために導入されました。
各対応プラットフォーム(モバイル, Web, デスクトップ) のさまざまなユースケースに対応するため、柔軟ですが抽象的で、とても多機能で複雑なものになりました。また、それらの API は、従来の Navigator 1.0 の API に追加する形で実装され、1.0 の API と併存しての使用も想定されているため、さらに複雑に見えます。
開発者コミュニティからは、Navigator 2.0 はそのままでは平均的な開発者が扱うには複雑すぎるとの評判が相次いでおり、Flutter 開発チームはその評判を認識しています。そして、Flutter 開発チームとコミュニティの有志のそれぞれで、Navigator 2.0 と Router を基礎として、すべてのユースケースには対応できないが、より単純で扱いやすいことを目指す Dart パッケージが検討され、開発が始まっています。
Navigator 2.0 は、完成したばかりです。当面は Navigator 1.0 のサポートも続けられるため、知見がコミュニティに蓄積されるまでは移行を控えるのが無難かもしれません。ただし、1.0 では実現できない機能が必要な場合は、移行が必要です。とくに、Web アプリに対応する場合は移行がほぼ必須です。これは、Web ブラウザーの戻る/進むボタンとロケーションバー(URL)の更新対応には Router が必要なためです。
Navigator 1.0 の課題と、Navigator 2.0 の特徴
従来の Navigator は、
push(), pop(), pushNamed(), pushAndRemoveUntil()
などの命令的 (imperative) な API で Navigator の history stack を操作します。この stack 構造への命令的な操作に対し、もっと柔軟に history を操作したい、Navigator を入れ子にしてそれぞれ管理をしたい(Nested Navigator)、Flutter の宣言的(Declarative) UI パラダイムに馴染むように、Navigator も宣言的なパラダイムに移行し、アプリの状態を反映して宣言的に再構築できるようにしてほしい、といった要望が寄せられていました。また、従来の Navigator では、Android の戻るボタンを制御する手法はややぎこちないものでしたし(WillPopScope など)、Web アプリの戻る/進むボタンとロケーションバーへの対応が正しくできていませんでした。
そういった課題を解消するため、Navigator 2.0 が開発されました。Page API と Router API から構成されています。
Page API によって、Flutter の宣言的 UI と同じく、Navigator もその管理下の Route を宣言的に管理できるようになりました。
また、Router API (Router も Widget です)は、OS からの Routing に関する情報、たとえばアプリ起動時の Routing 情報、Android OS の戻るボタンまたは Web ブラウザーの戻る/進むボタンからのイベント情報といったものを listen して解釈し、Navigator とそれが管理する Page stack の再構成につなげます。
以下において、Navigator 2.0 のうち、Pages API の基本を解説します。
Pages API を使用する Navigator の基本
Pages API は、Navigator class に新たに指定できる API です。
Page API を指定した Navigator と Router を組み合わせると複雑さが増すため、段階的に理解するために、まずは Navigator 単体で、その Pages API を使用しての状態管理手法を見ていきます。(実際のアプリ開発では、通常は さらに Router を組み合わせることになるでしょう。)
Pages の宣言とその更新、そして Route
Navigator class は、そのコンストラクターにList<Page<dynamic>> pages
名前付き引数を持ちます。Page
は、Route
の構成を記述する immutable なクラスです。Page と Route の関係は、Widget と Element の関係に類似しており、Page は Route の青写真 (blueprint) に例えられます。
アプリの状態が更新され、Flutter の各 Widget がその状態を反映するためにリビルドされる際、Navigator もその対象になっていたら同様にリビルドされます。Navigator は StatefulWidget のサブクラスです。そのリビルドの際、この pages の構成が前回の構成と比較され、変更がされていれば、Navigator はその pages を元に route 情報を更新します。
なお、各 page が変更されているかどうかの判定は、canUpdateで行っています。これは override できます。
Navigator によって管理された pages はList
ですが、これを 概念的には stack とみなし、それぞれの page に紐ついた Widget がそれぞれ build されます。そして、通常は一番上の Page、つまり page list の last index の Page (の Route) に紐ついた Widget をもとに、画面が表示されるでしょう。
Pages API を使用する Navigator 2.0 のサンプルアプリ
ここまでを理解した上で、この公式解説記事のサンプルコードを見ましょう。
(この段階のサンプルコードでは、Android の戻るボタンや Web ブラウザーの戻る/進むボタンに対応していません。その対応は、Router を Navigator と組み合わせることで実現します。)
ここから、Navigator が pages を管理し、その pages がアプリの状態を反映して再構成される部分を抜粋したものが以下です。
class _BooksAppState extends State<BooksApp> {
Book _selectedBook;
List<Book> books = [
Book('Stranger in a Strange Land', 'Robert A. Heinlein'),
Book('Foundation', 'Isaac Asimov'),
Book('Fahrenheit 451', 'Ray Bradbury'),
];
Widget build(BuildContext context) {
return MaterialApp(
title: 'Books App',
home: Navigator(
pages: [
MaterialPage(
key: ValueKey('BooksListPage'),
child: BooksListScreen(
books: books,
onTapped: _handleBookTapped,
),
),
if (_selectedBook != null) BookDetailsPage(book: _selectedBook)
],
onPopPage: (route, result) {
if (!route.didPop(result)) {
return false;
}
// Update the list of pages by setting _selectedBook to null
setState(() {
_selectedBook = null;
});
return true;
},
),
);
}
void _handleBookTapped(Book book) {
setState(() {
_selectedBook = book;
});
}
}
setState
によって Navigator を含む Widget がリビルドされるたびに、Navigator.pages の内容がそのアプリの状態が反映されて変更されます。Navigator.pages に BooksListScreen
を内包する page は常時存在しますが、_selectedBook
が null
でない場合のみ、BookDetailsPage
が追加されています。
if (_selectedBook != null) BookDetailsPage(book: _selectedBook)
従来の Navigator 1.0 API では、Navigator.push()
, Navigator.pop()
といった命令型 API で Navigator が管理する stack を操作しましたが、2.0 では、他の Widget と同様に、アプリの状態を反映して Navigator をリビルドすることを通じて Navigator の pages list を更新することで、pages の前回と差分を比較することを通じて Route 情報を更新します。page が削除されればそれに紐付く route も削除されます。
onPopPage
Navigator のコンストラクターに pages
を指定したならば、併せて onPopPage
も必ず指定します。指定しなければ、実行時に assertion エラーが発生します。
onPopPage
は、pages
の指定によって宣言型の Route の状態管理を選択した際に、従来の命令型のNavigator.pop()
を実行した際にその処理方法を指定するコールバックです。そのコールバック処理は、Navigator.pages
に、Navigator.pop()
の対象になる route に紐付く page が存在する場合にのみ、実行されます。そのコールバックでは、pop()に紐ついた page が、Navigator の pages から削除されて rebuild されるように状態を更新する処理を書きます。その処理内容は個別のアプリの構成に依ります。
以下は、先のサンプルコードの、pop の際の コールバック処理を抜粋したものです。
onPopPage: (route, result) {
if (!route.didPop(result)) {
return false;
}
// Update the list of pages by setting _selectedBook to null
setState(() {
_selectedBook = null;
});
return true;
},
正しい状態を維持するため、状態更新処理(上記コードではsetState
部分)の前段階として、route.didPop(result)
で pop が成功していたかどうかを判定し、成功していた時だけ状態更新処理をします。
Navigator.pop()
は 2.0 登場以前から存在する命令的な API です。だれもがこの pop()を書いたことがあるはずです。Navigator 2.0 でもこれまでの命令的な API を併用できます。Navigator.pop()
は既存の Widget の内部処理でも使用されています。たとえば AppBar
は、 pop 可能な状態ならば自動的に AppBar
に BackButton
を追加し、その action で Navigator.pop()
が実行され得ます。
命令型(Imperative) API との併用と Pageless Route
ここまでで見てきたように、Navigator 2.0 で新たに宣言型の Page ベースの Route 管理手法が導入されましたが、Navigator 2.0 でも、現在我々が Navigator 1.0 で使用している、push(), pop()といった命令型の API を併用できます。
理由としては、2.0 への移行に伴う破壊的変更を最小限にし、円滑に移行できるようにすること、そしてダイアログなどの一部のユースケースでは命令型 API で処理するほうが単純なコードになるからです。(コミュニティの一部からは、「ダイアログも宣言型で管理するべきだ」との異論もあります)
Navigator.pages
で page list を宣言し、宣言型の状態管理をしている Navigator で、push(), pushNamed() などの命令型の API で page に紐ついていない Route を追加 (push) した場合は、Navigator はどのようにその状態を管理するのでしょうか。
それらは Pageless Route と名付けられています。Pageless Route はすべて、その下部にある最初の Page に紐ついた Route と束ねられて管理されます。その Page が pages のなかで移動した(pages list の要素の順序が変わり pages stack が再構成された)場合もその束ねられた Pageless Route たちはすべて一緒に移動しますし、Page が削除されれば一緒に削除されます。
DevFest Saudi Arabia 2020: Navigator 2.0 - YouTube
独自の Page class を実装する。
上記サンプルコードでは Page を継承したMaterialPage
を使用しています。開発者は、Page class を継承して独自の処理を追加することも自由にできます。たとえば、独自の transition animation を追加できます。
class BookDetailsPage extends Page {
final Book book;
BookDetailsPage({
this.book,
}) : super(key: ValueKey(book));
Route createRoute(BuildContext context) {
return PageRouteBuilder(
settings: this,
pageBuilder: (context, animation, animation2) {
final tween = Tween(begin: Offset(0.0, 1.0), end: Offset.zero);
final curveTween = CurveTween(curve: Curves.easeInOut);
return SlideTransition(
position: animation.drive(curveTween).drive(tween),
child: BookDetailsScreen(
key: ValueKey(book),
book: book,
),
);
},
);
}
}
Learning Flutter’s new navigation and routing system | by John Ryan | Flutter | Medium
その他
transitionDelegate
transitionDelegate は、Navigator の Page に紐ついた Route がどのようにアニメーションして表示または削除されるのかを指定する API です。指定は任意です。解説量を減らすため、この記事では説明しません。(いつか別記事で解説したい。)
reportsRouteUpdateToEngine
解説量を減らすため、この記事では説明しません。
Pages API を使用する Navigator のまとめ
- Pages API によって、Navigator に宣言型の Route の状態管理手法を追加しました。
- onPopPage によって、Navigator.pop()実行時のコールバック処理を宣言できます。
- transitionDelegate(未解説)によって、Page の追加、削除時のアニメーションを指定できます。
後編: Router の基本の解説と、その先の Navigator 2.0 関連最新状況の解説へ
ここまで見てきた Navigator の Pages API だけでは、Android の戻るボタンや Web ブラウザーの戻る/進むボタン、ロケーションバーの URL の更新に対応できません。それらは、Router API でホストプラットフォーム OS と通信することで対応可能になります。後編で、Router API と Pages API を組み合わせる手法を解説します。
Navigator 2.0 は非常に複雑ですが、Pages API と Router API を切り分けて考えると理解しやすくなります。
また、Navigator 2.0 をこれから普及させるにあたっての状況と、その議論を読みつつ私が Navigator 2.0 を学習した過程で作ったもうすこし実際的なサンプルアプリのコードも共有予定です。
cf.
Learning Flutter’s new navigation and routing system | by John Ryan | Flutter | Medium
Discussion