本記事はFlutter Advent Calendar 2023の9日目の記事です!
はじめに
Flutterでは、AndroidやiOSの各プラットフォームに対応したネイティブライクな画面遷移アニメーションがデフォルトで用意されており、これらは多くの場合で十分に機能します。
しかしFlutterにおいては、AndroidとiOSのデザインを統一し、独自の世界観を持ったアプリケーションを制作することも多いのではないでしょうか?
そのような時、画面遷移アニメーションもまた世界観を演出する一助となります。
本記事では、魅力的な画面遷移アニメーションの基本と応用について、私が開発した画面遷移アニメーションのパッケージ turn_page_transition の実装経験を踏まえて解説します。
turn_page_transitionの紹介
turn_page_transitionはその名の通り、ページをめくるような画面遷移を目指して作成したアニメーションです。
この魅力的なアニメーションも、基本的なアニメーションのロジックとCustomPaintによって作成されています。
オリジナル画面遷移アニメーションの実装
この章ではオリジナル画面遷移アニメーションを実装するために必要なコードについて解説していきます。
CustomPaint
まずはアニメーションを作成するためにCustomPaintについて解説します。
アニメーションでは0.0 ~ 1.0で変動する値であるanimetion
のとある時点t
(0 <= t <= 1.0)において、遷移先の画面がどのように表示されるかがわかれば自然と作るべきCustomPaintが見えてきます。
今回この記事でCustomPaintの基本も解説しようかと思ったのですが、ちょうどCustomPaintについての記事がFlutter Advent Calendar 2023 8日目に投稿されていますのでこちらをご覧ください。→ CustomPaintでクリスマスツリーを作ってみた
実は自分でも触れるコードサンプルを作成はしていたので、よければそちらも見てみてください。 → DartPad
画面遷移アニメーションのためのCustomPaint
画面遷移アニメーションでは、画面遷移開始時点(animation == 0.0)では遷移先の画面は一歳表示されておらず、画面遷移終了時点(animation == 1.0)で遷移先の画面が全て表示されているということは意識する必要があります。
そういった意味では、画面の範囲を指定しただけくり抜くことのできるClipPathが画面遷移アニメーションにおいて使いやすいです。
ClipPathはCustomPaintとは異なるWidgetではあるものの、くり抜く範囲の指定の仕方はCustomPaintの方法をそのまま使うことができます。
ClipPathを使用する際には次のように、clipperにCustomClipper型のオブジェクトを、childにはWidget型のオブジェクトを渡すことで、childの特定の部分を表示または非表示にできます。今回に関してはchildは遷移先の画面となりますね。
ClipPath(
clipper: _CustomClipper()
child: ..., // 遷移先の画面
),
clipperではchild(遷移先画面)のうちどの部分を表示するかを、getClipメソッドをoverrideすることで指定します。
次のコード例では、画面の上部からアニメーションの進行に応じて段階的に表示されるアニメーションを実現しています。
class _CustomClipper extends CustomClipper<Path> {
const _CustomClipper({
required this.animation,
});
final Animation<double> animation;
@override
Path getClip(Size size) {
final width = size.width;
final height = size.height;
final animationProgress = animation.value;
final path = Path()
..lineTo(width, 0) // 画面左上から右上へ
..lineTo(width, height * animationProgress) // 画面右上から右下へ
..lineTo(0, height * animationProgress) // 画面右下から左下へ
..close();
}
return path;
}
@override
bool shouldReclip(_PageTurnClipper oldClipper) {
return true;
}
}
画面遷移アニメーションの設定方法
Flutterアプリケーションにカスタム画面遷移アニメーションを適用する方法はいくつかあります。主な方法は以下の通りです。
Navigator.pushで設定する
go_router等のNavigator 2.0が一般化されたため使われなくなりつつありますが、Flutterにおける画面遷移の基本といえばNavigator.push
です。
通常MaterialPageRoute
を使用する部分を次のように置き換えることで、アニメーションを任意のものに変更することができます。
// 画面遷移アニメーションをFadeに変更
Navigator.of(context).push(
PageRouteBuilder(
pageBuilder: (context, _, __) => const SecondPage(),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return ClipPath(
clipper: _CustomClipper(animation: animation),
child: child,
);
};
),
);
Themeで設定する
ThemeDataにpageTransitionsTheme
を設定して、アプリケーション全体にカスタム画面遷移アニメーションを適用することも可能です。
ただしアニメーションをそのままpageTransitionsTheme
に設定してあげることはできないため、次のようにPageTransitionBuilder
を継承したクラスを作成し、そのインスタンスを渡します。
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
...
pageTransitionsTheme: PageTransitionsTheme(
builders: {
// デフォルト TargetPlatform.android: ZoomPageTransitionsBuilder(),
TargetPlatform.android: CustomClipPageTransitionsBuilder(),
...
}
),
),
home: ...
);
}
}
継承したクラスを作成する必要があるとはいえ、その作成自体はとても簡単です。
次のようにbuildTransitions
をoverrideすることで任意をアニメーションを設定できます。
route
プロパティを使用していませんが、基本的には使うことは無いのでこの場では割愛します。(CupertinoPageTransitionsBuilder
だと使っていたりしますね。)
class CustomClipPageTransitionsBuilder extends PageTransitionsBuilder {
const CustomClipPageTransitionsBuilder();
@override
Widget buildTransitions<T>(
PageRoute<T> route,
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child,
) {
return ClipPath(
clipper: _CustomClipper(animation: animation),
child: child,
);
}
}
このようにして、Flutterでオリジナルの画面遷移アニメーションを実装することができます。
turn_page_transitionの実装
ここまで読んでくださったあなたは自らで独自のアニメーションを作成し、それを画面遷移アニメーションとして設定することができるようになりました。お疲れ様でした!
ここからはおまけとして、turn_page_transitionが実際、どのように実装されたのかを解説します。
ページをめくる画面遷移を実装する
本記事によると、0.0 ~ 1.0で変動する値であるanimetion
のとある時点t
(0 <= t <= 1.0)において、遷移先の画面がどのように表示されるかがわかれば自然と作るべきCustomPaintが見えてくるとのことでした。
ではturn_page_transitionの「とある時点t
での画面」ではどのようになっているのでしょうか?
下の画像は青い画面から緑の画面に遷移するturn_page_transitionのt = 0.5
時点の画面です。
この画面遷移に違和感はありませんか?
画面遷移は遷移前の画面に遷移後の画面をStackすることで行われます。
しかしページをめくるということは、既にStackされているページを取り除くような挙動をしなくてはなりません。
その挙動を実現するために画面を
- 遷移前の青い画面
- ページの裏側のように見える図形
- めくられて現れたように見える遷移先の緑の画面
に分解して考えましょう。
2の「ページの裏側のように見える図形」を取り除いてみると案外単純で、「めくられて現れたように見える遷移先の緑の画面」はその実、画面の右上から左側に向けて徐々に表示されていっているだけなのです。
turn_page_transitionでの「とある時点t
での画面」についても考えます。
画面全体を「開かれた本の右側」、青色の画面と緑色の画面の境界を「ページの折り目」だと考えてください。
t = | 0.2 | 0.5 | 0.9 |
---|---|---|---|
ページをめくるアニメーションの中で意識したポイントの一つが「境界の両端」が画面左端に辿り着くまでの「速度」です。
ページは「本の中央」から生えているため、ページをめくり終えたt=1.0
の時点ではページの折り目は「本の中央の谷間」と並行となる、つまりt=1.0
で「境界の両端」がどちらもちょうどが画面左端に辿り着くはずです。
turn_page_transitionでは単純にアニメーションの時間を1秒として次のように計算しました。
境界下端が画面右下の角にきた時点をte
、
t時点での境界上端の位置をxt
とすると
境界上端の速度v1
、境界下端の下方向への速度v2y
、横方向への速度v2x
は
xt = 1 / te
v1 = 1
v2y = H / te
v2x = (W - xt) / (1 - te)
よって時点t
における、遷移先の緑色の画面をくり抜くOffsetは
右上の角 = (W, 0)
境界上端 = (W * (1 - t), 0)
境界下端 = (W, H * t / te) or (W - (W - 1 / te)(t - te) / (1 - te)), H)
上記の計算ができればt
時点での画面の状態がわかるので、ClipPathで表示部分をくり抜きましょう。
CustomPaintのコード
return CustomPaint(
child: ClipPath(
clipper: _PageTurnClipper(animation: animation),
child: child,
),
);
class _PageTurnClipper extends CustomClipper<Path> {
const _PageTurnClipper({required this.animation});
final Animation<double> animation;
// 表示するページをくり抜くために範囲をpathで指定する
@override
Path getClip(Size size) {
final topCorner = ...; // 右上の角のOffset
final foldUpperCorner = ...; // 境界の上端のOffset
final foldLowerCorner = ...; // 境界の下端のOffset
final path = Path()
..moveTo(topCorner.dx, topCorner.dy) // 基準を画面の右上の角に移動
..lineTo(foldUpperCorner.dx, foldUpperCorner.dy)
..lineTo(foldLowerCorner.dx, foldLowerCorner.dy)
..close();
return path;
}
@override bool shouldReclip(_PageTurnClipper oldClipper) { ... }
}
めくられたページを実装する
次は画面の要素のうち、先ほどは考えなかった部分である、「ページの裏側のように見える図形」について考えましょう
この部分は言い換えれば「ページのめくられた部分」を表しています。
ということは顕になった「遷移先の緑色の画面」と同じ面積ですね?
また、青色の画面と緑色の境界がページの折り目であることを考えると、「ページのめくられた部分」と「遷移先の緑色の画面」は、「ページの折り目」に対して線対称のようです。
右上の角からページの折り目に対して垂線を引き、右上の角と垂線・折り目の交点との距離と同じ距離となる点を探せばよさそうです。
...ここから先の計算に関しては一番最後のおまけに載せておきます。
計算さえできればあとはCustomPaintで描くだけです!
CustomPaintにはchild
ともう一つ、foregroundPainter
という値を渡せるので、遷移先の画面をClipした箇所はchildに、ページのめくられた部分はforegroundPainterに置くことでページをめくるアニメーションを表現しました。
return CustomPaint(
foregroundPainter: _OverleafPainter(
animation: animation,
color: overleafColor,
animationTransitionPoint: transitionPoint,
direction: direction,
),
child: Align(
alignment: alignment,
child: ClipPath(
clipper: _PageTurnClipper(
animation: animation,
animationTransitionPoint: transitionPoint,
direction: direction,
),
child: Align(
alignment: alignment,
widthFactor: animation.value,
child: child,
),
),
),
);
また、画面遷移アニメーションの設定方法で今回解説したやり方ではPageRouteBuilderの中でアニメーションを設定しましたが、turn_page_transitionではPageRouteを継承したTurnPageRouteを作成しているため、使用する際には記述量は減って使いやすくなっています。
もし機会があればturn_page_transitionを試してみてください!
最後に
このようにして、魅力的な画面遷移アニメーションは簡単に?作成することができるという解説記事でした。
実はもう少し解説できる内容もあるので、今後この記事自体をアップデートするかもしれません(モチベーションがあれば)。
もしこの記事が誰かの役に立つことがあれば幸いです。
おまけ
turn_page_animationを算出した努力の跡
多分最初の方は計算ミスしていた気がする