【Flutter】Routeの正体 ~ PageRouteとHeroアニメーション
スーパーマン by いらすとや
はじめに
「Route(ルート)」って root と混同するしなんだか意味をつかみづらい言葉だな、と思うのは私だけでしょうか。。
デジタル大辞泉で意味を調べると...
- 道。道路。また、きまった道筋。路線。「観光ルート」
- 経路。手づる。「販売ルート」
とあります。そして、こちらの公式ページでは、
Terminology: A Route describes a page or screen in a Flutter app.
(日本語訳)Route
とは Flutter アプリにおけるページや画面を表すものです。
と明快な用語解説がされている一方で、こちらの公式APIドキュメントには
An abstraction for an entry managed by a Navigator.
(日本語訳)Navigator
により管理されるエントリーの抽象概念。
と初見では頭の中に「?」が浮かんでしまうような説明がなされています。
Route
という概念は Flutter に限らず他のフレームワークやネットワークの世界でも使用されるワードであり、それぞれにおける意味合いは多少前後することもあるかと思います。しかし概ね、ノード A から ノード B に移動する途中、あるいは移動後の「様々な情報・状態・メソッド」を保有する「何か」を概念化したもので、それを表現するのにピッタリなのが 「Route(ルート、経路)」という言葉だったのかなと勝手に思っています。
実際、抽象クラスである Route
には多くのプロパティ・メソッドがあり、その Route
を継承する抽象クラスにも、それを継承する別の抽象クラスにも、さらにそれを継承する具象クラスにも多くの設定が施されています。
この記事ではこれらの Route
周りの仕組みを抽象クラスから少しずつひも解いていき、Flutter のアプリ開発において誰もが利用するであろう Route
に慣れ親しむことを目的としています。
(※ 簡略化のため Cupertino 系の Route については触れていません)
きっかけ
Hero ウィジェットはご存じでしょうか。
Hero ウィジェット動作例
このウィジェットは Flutter の中でもかなり異質なウィジェットで、同じタグ(Hero.tag
)を持つ2つの Hero
インスタンスが存在して初めて成り立ちます。この2つの Hero
の Rect
(Offset
+ Size
のこと。ある起点からの、四角を成す4点の相対位置を表す)の値を元に Tween を作成し、画面表示 A から画面表示 B の遷移時にスムーズな Hero アニメーションを見せてくれます。
詳細については後半で説明を加えたいと思いますが、この同じタグを持つ2つの Hero
ウィジェットは、それぞれ隣り合う異なる「Route」に属している必要があります。また、アニメーションを作動させるには Navigator.of(context).push
等を利用して Navigator
に「Route」エントリーを追加する必要があります。
ところがどのような「Route」でもいいというわけではなく、Flutter に2種類ある「Route」のベースクラス PageRoute
と PopupRoute
(後述)のうち、前者しかアニメーションを発動させることができないようです。
実は個人のプロジェクトにおいて PopupRoute
を生成する showDialog
等のダイアログ表示系のメソッドと Hero
を組み合わせることを考えていたのですが、Hero アニメーションに使用できないことが分かり、代替案として PageRoute
をダイアログっぽく見えるようカスタマイズしたことがありました。
その時に「Route」についてあれこれ調べ、Hero
に限らず Navigator
、そして Navigator 2.0 と呼ばれていた Router(ルータ)API や Page API を理解するには、まず「Route」から知る必要があるなとその重要性を感じたことが今回のまとめのきっかけです。
Route(ルート)全体像
- 枠が点線の四角は抽象クラス
- 枠が実線の四角は具象クラス
- Cupertino 系は全体像を把握しやすくするため省略
- 番号は以降の見出し番号に対応
Route
とは
1. 全体像で分かる通り、全てのルートを包括する抽象ルートです。
Navigator
と連携して「アプリに表示する画面」を決定するための、様々なプロパティやメソッドをインタフェースに持ちます。これらをひも解くと、少しルートの正体が見えてくると思います。
例えば...
-
navigator
:NavigatorState?
... このルートがどのNavigator
に属しているか。 -
isActive
:bool
... このルートがNavigator
に属しているか否か。 -
isCurrent
:bool
... このルートがNavigator
が管理するルートエントリーのリストの一番上にあるか( ≒ 現在画面に表示されているルートか)否か。 -
overlayEntries
:List<OverlayEntry>
... このルートにひも付くOverlayEntry
のリストを規定。
-
settings
:RouteSettings
... このルートのRouteSettings
。値の型がRouteSettings
の場合は「ページレスルート」と呼ばれる一方、型がRouteSettings
を継承するPage
の場合は「ページベースルート」と呼ばれる(参考)。
-
install()
:void
... このルートがNavigator
に追加される時に呼び出される。Route.overlayEntries
リストを生成するために使われ、このメソッド呼び出し後はリストにOverlayEntry
が最低一つは存在しなくてはならない。 -
didPop()
:bool
... このルートをポップするリクエストが発生すると呼び出される。true を返すことで、Navigator
はルートエントリーのリストからこのルートを除き、様々なメソッドを経て遷移アニメーションが完了した後にRoute.navigator
に null を代入する。
💬 どうやら Route
とはウィジェットを生成してくれる OverlayEntry
を複数持ち、Navigator
と連携するための様々な設定情報や状態を持つもの、というのがなんとなく分かりますね。以降、基本ですます調を省かせていただきます。
OverlayRoute
とは
2. Route
を継承する抽象クラス。
Navigator
にルートが追加されてルートの install()
メソッドが実行される時に、内部で createOverlayEntries()
を呼び出し、OverlayEntry
を overlayEntries
リストにキャッシュするインタフェースを持つ。
Navigator
は自身が管理するルートエントリーのリストから各ルートの overlayEntries
を集約し、それらを初期エントリーとして Overlay
ウィジェットを生成する。
Navigator
を介してルートから Overlay
を作れるようにしたクラス、もしくは Route
に Overlay(「上に被せる物」) の概念を加えたクラスだと言える。
TransitionRoute
とは
3. OverlayRoute
を継承する抽象クラスで、このルートが Navigator
に追加された時・除かれた時のトランジション用アニメーション、そしてこのルートの上層にルートが追加された時の「このルートの」トランジション用アニメーションを規定するクラスである。
また、これらアニメーション用の AnimationController
および Animation<double>
の生成、適切なタイミングでのアニメーションの再生・逆再生などを担当する。OverlayRoute
にアニメーションの概念を加えたクラスだと言える。
ちなみに生成される Animation<double>
には TransitionRoute._animation
と TransitionRoute._secondaryAnimation
の2種類があり、これは後述する PageRouteBuilder
等で transitionsBuilder
や pageBuilder
に引数として渡される animation
と secondaryAnimation
である。
ModalRoute
とは
4. TransitionRoute
を継承する抽象クラスで、画面全体を覆って、その下層ルートへのユーザーによるアクションをシャットダウンする「モーダルバリア(modal barrier)」と呼ばれる OverlayEntry
(もしくはウィジェット)を生成するための設定やメソッドが追加されている。
また、このクラスから buildPage()
: Widget メソッドが規定されており、このメソッドを元に「モーダルバリア」とは別の「モーダルスコープ(modal scope)」と呼ばれる OverlayEntry
(もしくはウィジェット)が生成される。
ユーザーアクションをシャットダウンするために内部では IgnorePointer ウィジェットが使われている
このモーダルバリアは設定により Color
や ImageFilter
を付けることが可能であるが、未設定の場合は透明となる。つまり、「画面全体を覆う」とはいえ、必ずしもそれが不透明(opaque)とは限らず、ページを表示するルートにもダイアログを表示するルートにもなり得る。
また、モーダルバリア部分をタッチ等することで、そのルートをポップすることができるか否かを設定する barrierDismissible
: bool プロパティもこのクラスで規定されている。
そして、モーダルスコープ部分の表示・非表示を決めているのが offstage
: bool プロパティである。Navigator
にルートが追加されると createOverlayEntries()
が ModalRoute
内部で呼び出されてモーダルスコープが作られるが、このウィジェットで使われている Offstage ウィジェットにこのルートの offstage
値があてがわれ、実際の表示・非表示が行われる。
ちなみに ModalRoute
は ModalRoute.of(context)
により、その BuildContext
(エレメント)が属するルートを取得することができる。これにより、ルートに渡されている引数などの情報を取得することができる。
PageRoute
/ PopupRoute
とは
5. PageRoute
も PopupRoute
も ModalRoute
を継承する抽象クラスである。この2つのルートは一卵性双生児ほどではないが、二卵性双生児ほどには同等のものであり、主な細かい違いは以下の通りである。
-
PageRoute
はopaque
: bool のデフォルト値が true(モーダルバリアの部分が不透明)に設定されているのに対し、PopupRoute
は false(モーダルバリアの部分が透明)に設定されている。 -
PageRoute
はbarrierDismissible
: bool のデフォルト値が false(モーダルバリアをタップしてもルートをポップできない)に設定されている。 -
PopupRoute
はmaintainState
: bool のデフォルト値が true(ルートが非表示になっても状態を保持する)に設定されている。
そして PageRoute
は fullscreenDialog
: bool という独自のプロパティを持つ。この設定の効用については次項の MaterialPageRoute
で説明を加えたい。
MaterialPageRoute
/ PageRouteBuilder
とは
6. ここでやっと具象クラスが登場する。MaterialPageRoute
と PageRouteBuilder
はともに PageRoute
を継承し、基本的には画面全体を覆うタイプのウィジェットを生成するルートである。そして PageRoute
を継承して独自のルートクラスを立てるよりは若干便利な程度に、様々なプロパティにデフォルト値が設定されている。
MaterialPageRoute
と PageRouteBuilder
の主な違いは、ページ生成用およびトランジション用のアニメーションをカスタマイズできるか否かという点に表れている。
MaterialPageRoute
が builder()
: Widget メソッドさえ設定すれば、後は内部で ThemeData.pageTransitionsTheme
もしくは ThemeData.platform
(デフォルト値=実行中のプラットフォーム)の値に応じたアニメーションを適用してくれるのに対して、PageRouteBuilder
は pageBuilder()
: Widget や transitionBuilder()
: Widget メソッドを使用して独自のアニメーションを設定する必要がある(もちろん、アニメーションを設定しない選択肢も可能)。
また MaterialPageRoute
において fullscreenDialog
: bool を true にした場合、MaterialApp
における AppBar
の左上に出るボタンが BackButton の代わりに CloseButton になり、Navigator
にこのルートが追加された時の「下層ルートの」アニメーションが無効になる。
加えて、ThemeData.pageTransitionsTheme
や Themedata.platform
を特に設定しておらず、実行中のプラットフォームが iOS もしくは macOS の場合は、MaterialPageRoute
のトランジション用アニメーションは画面下方から上方にスライドするものに変わる。このトランジション効果の切り替えは CupertinoPageTransitionsBuilder
が呼び出す CupertinoRouteTransitionMixin.buildPageTransitions
で実装されている。
MaterialPageRoute
なのにも関わらず、このように Cupertino のトランジション効果が適用される理由は、マテリアルデザインのガイドラインに記されている通りである。
RawDialogRoute
/ DialogRoute
とは
7. RawDialogRoute
は PopupRoute
を継承する具象クラスであり、さらにそれを継承するのが DialogRoute
である。PopupRoute
が魚という「概念」、RawDialogRoute
が生魚、DialogRoute
が焼き魚と例えれば分かりやすいだろうか 🐟
これらはルートでもあるため、Navigator.push
等で追加することができるが、Flutter においてはグローバルで定義されている showGeneralDialog()
や showDialog()
メソッドと、Dialog 系ウィジェットとの組み合わせ経由で使用されることが多いかと思う。
ちなみに、RawDialogRoute
を生成して Navigator.push
するのが showGeneralDialog()
であり、DialogRoute
を生成して Navigator.push
するのが showDialog()
である。
RawDialogRoute
にはモーダルバリア部分の設定( barrierColor
や barrierDismissible
)に初期値が与えられ、トランジション効果にデフォルトのフェード効果が適用されている。また、コンストラクタで指定するウィジェットビルダーを、Semantics ウィジェットでラッピングすることでダイアログのアクセシビリティ対応を行ってくれる。
DialogRoute
にはそれに加えて、barrierLabel
のデフォルト値を MaterialLocalizations
のそれにする、ダイアログを SafeArea
でラッピングして OS が使用部分と被らないようにする等の設定が追加されている。
💬 以上が Cupertino 系以外の全ルートの概要です。これで少しはルートの正体に近づけたかと思います。
PageRoute
を作って Hero
と組み合わせる
カスタムの 冒頭の Hero アニメーションの話に戻ります。
前述の通り、Hero アニメーションは同じタグを持つ2つの Hero
ウィジェットを、隣り合う異なるルートに配置し、画面遷移することでアニメーションが発動します。そして、この2つのルートは両者ともに PageRoute
である必要があります。
理由は私には分からないのですが、直接的な理由は、Hero アニメーションを発動させる HeroController
のソースコードで見ることができます。
このため、ダイアログと Hero アニメーションを組み合わせるには PageRoute
もしくは PageRouteBuilder
をカスタマイズして「ダイアログっぽく」なるように見せる必要があります。
説明は省略させていただきますが、以下はそのサンプルです。
class CustomPopupRoute extends PageRoute {
CustomPopupRoute({required this.builder}) : super();
final WidgetBuilder builder;
Widget buildPage(BuildContext context, Animation<double> animation,
Animation<double> secondaryAnimation) {
return builder(context);
}
Widget buildTransitions(BuildContext context, Animation<double> animation,
Animation<double> secondaryAnimation, Widget child) {
// アニメーションは Hero が担当するためここでは不要
// child は上記 buildPage メソッドの戻り値
return child;
}
bool get opaque => false;
Color? get barrierColor => Colors.black54;
bool get barrierDismissible => true;
String? get barrierLabel => 'Dismiss this dialog';
Duration get transitionDuration => const Duration(milliseconds: 1200);
bool get maintainState => true;
}
class CustomPopup extends StatelessWidget {
const CustomPopup({Key? key}) : super(key: key);
Widget build(BuildContext context) {
return Hero(
// 遷移元の Hero ウィジェットと同じタグを付ける
tag: kHeroTag,
child: Padding(
padding:
const EdgeInsets.only(top: 120, left: 45, right: 45, bottom: 30),
child: Material(
color: Colors.brown,
elevation: 10,
borderRadius: BorderRadius.circular(10),
),
),
);
}
}
Hero とポップアップの組み合わせ 完成例
結局このアニメーションは無駄なように思えて採用しませんでしたが(笑)、ルートの勉強になったのでヨシとします。以上です。
🙌 今年もよろしくお願いいたします 🎍
Discussion