【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 系は全体像を把握しやすくするため省略
- 番号は以降の見出し番号に対応
1. Route とは
全体像で分かる通り、全てのルートを包括する抽象ルートです。
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 と連携するための様々な設定情報や状態を持つもの、というのがなんとなく分かりますね。以降、基本ですます調を省かせていただきます。
2. OverlayRoute とは
Route を継承する抽象クラス。
Navigator にルートが追加されてルートの install() メソッドが実行される時に、内部で createOverlayEntries() を呼び出し、OverlayEntry を overlayEntries リストにキャッシュするインタフェースを持つ。
Navigator は自身が管理するルートエントリーのリストから各ルートの overlayEntries を集約し、それらを初期エントリーとして Overlay ウィジェットを生成する。
Navigator を介してルートから Overlay を作れるようにしたクラス、もしくは Route に Overlay(「上に被せる物」) の概念を加えたクラスだと言える。
3. TransitionRoute とは
OverlayRoute を継承する抽象クラスで、このルートが Navigator に追加された時・除かれた時のトランジション用アニメーション、そしてこのルートの上層にルートが追加された時の「このルートの」トランジション用アニメーションを規定するクラスである。
また、これらアニメーション用の AnimationController および Animation<double> の生成、適切なタイミングでのアニメーションの再生・逆再生などを担当する。OverlayRoute にアニメーションの概念を加えたクラスだと言える。
ちなみに生成される Animation<double> には TransitionRoute._animation と TransitionRoute._secondaryAnimation の2種類があり、これは後述する PageRouteBuilder 等で transitionsBuilder や pageBuilder に引数として渡される animation と secondaryAnimation である。
4. ModalRoute とは
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 (エレメント)が属するルートを取得することができる。これにより、ルートに渡されている引数などの情報を取得することができる。
5. PageRoute / PopupRoute とは
PageRoute も PopupRoute も ModalRoute を継承する抽象クラスである。この2つのルートは一卵性双生児ほどではないが、二卵性双生児ほどには同等のものであり、主な細かい違いは以下の通りである。
-
PageRouteはopaque: bool のデフォルト値が true(モーダルバリアの部分が不透明)に設定されているのに対し、PopupRouteは false(モーダルバリアの部分が透明)に設定されている。 -
PageRouteはbarrierDismissible: bool のデフォルト値が false(モーダルバリアをタップしてもルートをポップできない)に設定されている。 -
PopupRouteはmaintainState: bool のデフォルト値が true(ルートが非表示になっても状態を保持する)に設定されている。
そして PageRoute は fullscreenDialog: bool という独自のプロパティを持つ。この設定の効用については次項の MaterialPageRoute で説明を加えたい。
6. MaterialPageRoute / PageRouteBuilder とは
ここでやっと具象クラスが登場する。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 のトランジション効果が適用される理由は、マテリアルデザインのガイドラインに記されている通りである。
7. RawDialogRoute / DialogRoute とは
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;
@override
Widget buildPage(BuildContext context, Animation<double> animation,
Animation<double> secondaryAnimation) {
return builder(context);
}
@override
Widget buildTransitions(BuildContext context, Animation<double> animation,
Animation<double> secondaryAnimation, Widget child) {
// アニメーションは Hero が担当するためここでは不要
// child は上記 buildPage メソッドの戻り値
return child;
}
@override
bool get opaque => false;
@override
Color? get barrierColor => Colors.black54;
@override
bool get barrierDismissible => true;
@override
String? get barrierLabel => 'Dismiss this dialog';
@override
Duration get transitionDuration => const Duration(milliseconds: 1200);
@override
bool get maintainState => true;
}
class CustomPopup extends StatelessWidget {
const CustomPopup({Key? key}) : super(key: key);
@override
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