🦸‍♂️

【Flutter】Routeの正体 ~ PageRouteとHeroアニメーション

2022/01/01に公開

Routeを理解する ~ PageRouteとHeroアニメーション
スーパーマン by いらすとや

はじめに

Route(ルート)」って root と混同するしなんだか意味をつかみづらい言葉だな、と思うのは私だけでしょうか。。

デジタル大辞泉で意味を調べると...

  1. 道。道路。また、きまった道筋。路線。「観光ルート」
  2. 経路。手づる。「販売ルート」

とあります。そして、こちらの公式ページでは、

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 ウィジェット動作例
Hero ウィジェット動作例

このウィジェットは Flutter の中でもかなり異質なウィジェットで、同じタグ(Hero.tag)を持つ2つの Hero インスタンスが存在して初めて成り立ちます。この2つの HeroRectOffset + Size のこと。ある起点からの、四角を成す4点の相対位置を表す)の値を元に Tween を作成し、画面表示 A から画面表示 B の遷移時にスムーズな Hero アニメーションを見せてくれます。

詳細については後半で説明を加えたいと思いますが、この同じタグを持つ2つの Hero ウィジェットは、それぞれ隣り合う異なる「Route」に属している必要があります。また、アニメーションを作動させるには Navigator.of(context).push 等を利用して Navigator に「Route」エントリーを追加する必要があります。

ところがどのような「Route」でもいいというわけではなく、Flutter に2種類ある「Route」のベースクラス PageRoutePopupRoute(後述)のうち、前者しかアニメーションを発動させることができないようです

実は個人のプロジェクトにおいて PopupRoute を生成する showDialog 等のダイアログ表示系のメソッドと Hero を組み合わせることを考えていたのですが、Hero アニメーションに使用できないことが分かり、代替案として PageRoute をダイアログっぽく見えるようカスタマイズしたことがありました。

その時に「Route」についてあれこれ調べ、Hero に限らず Navigator、そして Navigator 2.0 と呼ばれていた Router(ルータ)API や Page API を理解するには、まず「Route」から知る必要があるなとその重要性を感じたことが今回のまとめのきっかけです。

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() を呼び出し、OverlayEntryoverlayEntries リストにキャッシュするインタフェースを持つ。

Navigator は自身が管理するルートエントリーのリストから各ルートの overlayEntries を集約し、それらを初期エントリーとして Overlay ウィジェットを生成する。

Navigator を介してルートから Overlay を作れるようにしたクラス、もしくは Route に Overlay(「上に被せる物」) の概念を加えたクラスだと言える。

3. TransitionRoute とは

OverlayRoute を継承する抽象クラスで、このルートが Navigator に追加された時・除かれた時のトランジション用アニメーション、そしてこのルートの上層にルートが追加された時の「このルートの」トランジション用アニメーションを規定するクラスである。

また、これらアニメーション用の AnimationController および Animation<double> の生成、適切なタイミングでのアニメーションの再生・逆再生などを担当する。OverlayRoute にアニメーションの概念を加えたクラスだと言える。

ちなみに生成される Animation<double> には TransitionRoute._animationTransitionRoute._secondaryAnimation の2種類があり、これは後述する PageRouteBuilder 等で transitionsBuilderpageBuilder に引数として渡される animationsecondaryAnimation である。

4. ModalRoute とは

TransitionRoute を継承する抽象クラスで、画面全体を覆って、その下層ルートへのユーザーによるアクションをシャットダウンする「モーダルバリア(modal barrier)」と呼ばれる OverlayEntry(もしくはウィジェット)を生成するための設定やメソッドが追加されている。

また、このクラスから buildPage(): Widget メソッドが規定されており、このメソッドを元に「モーダルバリア」とは別の「モーダルスコープ(modal scope)」と呼ばれる OverlayEntry(もしくはウィジェット)が生成される。

モーダルスコープとモーダルバリア
ユーザーアクションをシャットダウンするために内部では IgnorePointer ウィジェットが使われている

このモーダルバリアは設定により ColorImageFilter を付けることが可能であるが、未設定の場合は透明となる。つまり、「画面全体を覆う」とはいえ、必ずしもそれが不透明(opaque)とは限らず、ページを表示するルートにもダイアログを表示するルートにもなり得る。

また、モーダルバリア部分をタッチ等することで、そのルートをポップすることができるか否かを設定する barrierDismissible: bool プロパティもこのクラスで規定されている。

そして、モーダルスコープ部分の表示・非表示を決めているのが offstage: bool プロパティである。Navigator にルートが追加されると createOverlayEntries()ModalRoute 内部で呼び出されてモーダルスコープが作られるが、このウィジェットで使われている Offstage ウィジェットにこのルートの offstage 値があてがわれ、実際の表示・非表示が行われる。

ちなみに ModalRouteModalRoute.of(context) により、その BuildContext (エレメント)が属するルートを取得することができる。これにより、ルートに渡されている引数などの情報を取得することができる。

5. PageRoute / PopupRoute とは

PageRoutePopupRouteModalRoute を継承する抽象クラスである。この2つのルートは一卵性双生児ほどではないが、二卵性双生児ほどには同等のものであり、主な細かい違いは以下の通りである。

  • PageRouteopaque: bool のデフォルト値が true(モーダルバリアの部分が不透明)に設定されているのに対し、PopupRoute は false(モーダルバリアの部分が透明)に設定されている。
  • PageRoutebarrierDismissible: bool のデフォルト値が false(モーダルバリアをタップしてもルートをポップできない)に設定されている。
  • PopupRoutemaintainState: bool のデフォルト値が true(ルートが非表示になっても状態を保持する)に設定されている。

そして PageRoutefullscreenDialog: bool という独自のプロパティを持つ。この設定の効用については次項の MaterialPageRoute で説明を加えたい。

6. MaterialPageRoute / PageRouteBuilder とは

ここでやっと具象クラスが登場する。MaterialPageRoutePageRouteBuilder はともに PageRoute を継承し、基本的には画面全体を覆うタイプのウィジェットを生成するルートである。そして PageRoute を継承して独自のルートクラスを立てるよりは若干便利な程度に、様々なプロパティにデフォルト値が設定されている。

MaterialPageRoutePageRouteBuilder の主な違いは、ページ生成用およびトランジション用のアニメーションをカスタマイズできるか否かという点に表れている。

MaterialPageRoutebuilder(): Widget メソッドさえ設定すれば、後は内部で ThemeData.pageTransitionsTheme もしくは ThemeData.platform(デフォルト値=実行中のプラットフォーム)の値に応じたアニメーションを適用してくれるのに対して、PageRouteBuilderpageBuilder(): Widget や transitionBuilder(): Widget メソッドを使用して独自のアニメーションを設定する必要がある(もちろん、アニメーションを設定しない選択肢も可能)。

また MaterialPageRoute において fullscreenDialog: bool を true にした場合、MaterialApp における AppBar の左上に出るボタンが BackButton の代わりに CloseButton になり、Navigator にこのルートが追加された時の「下層ルートの」アニメーションが無効になる。

加えて、ThemeData.pageTransitionsThemeThemedata.platform を特に設定しておらず、実行中のプラットフォームが iOS もしくは macOS の場合は、MaterialPageRoute のトランジション用アニメーションは画面下方から上方にスライドするものに変わる。このトランジション効果の切り替えは CupertinoPageTransitionsBuilder が呼び出す CupertinoRouteTransitionMixin.buildPageTransitions で実装されている。

MaterialPageRoute なのにも関わらず、このように Cupertino のトランジション効果が適用される理由は、マテリアルデザインのガイドラインに記されている通りである。

7. RawDialogRoute / DialogRoute とは

RawDialogRoutePopupRoute を継承する具象クラスであり、さらにそれを継承するのが DialogRoute である。PopupRoute が魚という「概念」、RawDialogRoute が生魚、DialogRoute が焼き魚と例えれば分かりやすいだろうか 🐟

これらはルートでもあるため、Navigator.push 等で追加することができるが、Flutter においてはグローバルで定義されている showGeneralDialog()showDialog() メソッドと、Dialog 系ウィジェットとの組み合わせ経由で使用されることが多いかと思う。

ちなみに、RawDialogRoute を生成して Navigator.push するのが showGeneralDialog() であり、DialogRoute を生成して Navigator.push するのが showDialog() である。

RawDialogRoute にはモーダルバリア部分の設定( barrierColorbarrierDismissible )に初期値が与えられ、トランジション効果にデフォルトのフェード効果が適用されている。また、コンストラクタで指定するウィジェットビルダーを、Semantics ウィジェットでラッピングすることでダイアログのアクセシビリティ対応を行ってくれる。

DialogRoute にはそれに加えて、barrierLabel のデフォルト値を MaterialLocalizations のそれにする、ダイアログを SafeArea でラッピングして OS が使用部分と被らないようにする等の設定が追加されている。

💬 以上が Cupertino 系以外の全ルートの概要です。これで少しはルートの正体に近づけたかと思います。

カスタムの PageRoute を作って Hero と組み合わせる

冒頭の Hero アニメーションの話に戻ります。

前述の通り、Hero アニメーションは同じタグを持つ2つの Hero ウィジェットを、隣り合う異なるルートに配置し、画面遷移することでアニメーションが発動します。そして、この2つのルートは両者ともに PageRoute である必要があります。

理由は私には分からないのですが、直接的な理由は、Hero アニメーションを発動させる HeroControllerソースコードで見ることができます。

このため、ダイアログと Hero アニメーションを組み合わせるには PageRoute もしくは PageRouteBuilder をカスタマイズして「ダイアログっぽく」なるように見せる必要があります。

説明は省略させていただきますが、以下はそのサンプルです。

PageRoute のカスタマイズ例
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 とポップアップの組み合わせ 完成例
Hero とポップアップの組み合わせ 完成例

結局このアニメーションは無駄なように思えて採用しませんでしたが(笑)、ルートの勉強になったのでヨシとします。以上です。

🙌 今年もよろしくお願いいたします 🎍

Discussion