魅力的な画面遷移アニメーションの作り方

本記事は[Flutter Advent Calendar 2023](https://qiita.com/advent-calendar/2023/flutter)の9日目の記事です！

# はじめに
Flutterでは、AndroidやiOSの各プラットフォームに対応したネイティブライクな画面遷移アニメーションがデフォルトで用意されており、これらは多くの場合で十分に機能します。

しかしFlutterにおいては、AndroidとiOSのデザインを統一し、独自の世界観を持ったアプリケーションを制作することも多いのではないでしょうか？
そのような時、画面遷移アニメーションもまた世界観を演出する一助となります。

本記事では、魅力的な画面遷移アニメーションの基本と応用について、私が開発した画面遷移アニメーションのパッケージ turn_page_transition の実装経験を踏まえて解説します。

# turn_page_transitionの紹介
turn_page_transitionはその名の通り、ページをめくるような画面遷移を目指して作成したアニメーションです。
この魅力的なアニメーションも、基本的なアニメーションのロジックとCustomPaintによって作成されています。

<img src="https://user-images.githubusercontent.com/44453803/174470088-18e616cc-26ef-4e7d-aaf6-d1967f974722.gif" width="300">

# オリジナル画面遷移アニメーションの実装
この章ではオリジナル画面遷移アニメーションを実装するために必要なコードについて解説していきます。

### CustomPaint
まずはアニメーションを作成するためにCustomPaintについて解説します。
アニメーションでは0.0 ~ 1.0で変動する値である`animetion`のとある時点`t`(0 <= t <= 1.0)において、遷移先の画面がどのように表示されるかがわかれば自然と作るべきCustomPaintが見えてきます。

今回この記事でCustomPaintの基本も解説しようかと思ったのですが、ちょうどCustomPaintについての記事がFlutter Advent Calendar 2023 8日目に投稿されていますのでこちらをご覧ください。→ [CustomPaintでクリスマスツリーを作ってみた](https://qiita.com/maguroumai/items/fa0178bd59f0710b6b23)

実は自分でも触れるコードサンプルを作成はしていたので、よければそちらも見てみてください。 → [DartPad](https://dartpad.dev/?id=d612a4a905948848da9093b07fe5ed7d)

### 画面遷移アニメーションのためのCustomPaint
画面遷移アニメーションでは、画面遷移開始時点(animation == 0.0)では遷移先の画面は一歳表示されておらず、画面遷移終了時点(animation == 1.0)で遷移先の画面が全て表示されているということは意識する必要があります。

そういった意味では、画面の範囲を指定しただけくり抜くことのできるClipPathが画面遷移アニメーションにおいて使いやすいです。
ClipPathはCustomPaintとは異なるWidgetではあるものの、くり抜く範囲の指定の仕方はCustomPaintの方法をそのまま使うことができます。

ClipPathを使用する際には次のように、clipperにCustomClipper型のオブジェクトを、childにはWidget型のオブジェクトを渡すことで、childの特定の部分を表示または非表示にできます。今回に関してはchildは遷移先の画面となりますね。

```dart
ClipPath(
  clipper: _CustomClipper()
  child: ..., // 遷移先の画面
),
```

clipperではchild(遷移先画面)のうちどの部分を表示するかを、getClipメソッドをoverrideすることで指定します。
次のコード例では、画面の上部からアニメーションの進行に応じて段階的に表示されるアニメーションを実現しています。
```dart
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;
  }
}
```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/278901/ce73c175-3dad-48d1-f143-40defa4d11d0.gif" width="300">

### 画面遷移アニメーションの設定方法
Flutterアプリケーションにカスタム画面遷移アニメーションを適用する方法はいくつかあります。主な方法は以下の通りです。

##### Navigator.pushで設定する
go_router等のNavigator 2.0が一般化されたため使われなくなりつつありますが、Flutterにおける画面遷移の基本といえば`Navigator.push`です。
通常`MaterialPageRoute`を使用する部分を次のように置き換えることで、アニメーションを任意のものに変更することができます。
```dart
// 画面遷移アニメーションを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`を継承したクラスを作成し、そのインスタンスを渡します。
```dart
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`だと使っていたりしますね。)

```dart
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`時点の画面です。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/278901/1dc931cb-5c13-bb8f-dd2c-bc7d224499d3.png" width="300">

この画面遷移に違和感はありませんか？

画面遷移は遷移前の画面に遷移後の画面をStackすることで行われます。
しかしページをめくるということは、既にStackされているページを取り除くような挙動をしなくてはなりません。

その挙動を実現するために画面を

1. 遷移前の青い画面
1. ページの裏側のように見える図形
1. めくられて現れたように見える遷移先の緑の画面

に分解して考えましょう。

2の「ページの裏側のように見える図形」を取り除いてみると案外単純で、「めくられて現れたように見える遷移先の緑の画面」はその実、画面の右上から左側に向けて徐々に表示されていっているだけなのです。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/278901/19d64c1c-e399-1364-5690-3dd112bde7d2.png" width="300">

turn_page_transitionでの「とある時点`t`での画面」についても考えます。
画面全体を「開かれた本の右側」、青色の画面と緑色の画面の境界を「ページの折り目」だと考えてください。

| t = | 0.2 | 0.5 | 0.9 |
| -- | -- | -- | -- |
|  | <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/278901/9e372721-b902-e69d-c78e-9c5637f1f379.png" width="300"> | <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/278901/19d64c1c-e399-1364-5690-3dd112bde7d2.png" width="300"> | <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/278901/240fb882-0f10-ee94-f2e2-fde30c06a631.png" width="300"> |

ページをめくるアニメーションの中で意識したポイントの一つが「境界の両端」が画面左端に辿り着くまでの「速度」です。
ページは「本の中央」から生えているため、ページをめくり終えた`t=1.0`の時点ではページの折り目は「本の中央の谷間」と並行となる、つまり`t=1.0`で「境界の両端」がどちらもちょうどが画面左端に辿り着くはずです。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/278901/c2fab5ed-162e-74cf-dcd0-b8c1d5f489d6.png" width="500">

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で表示部分をくり抜きましょう。
<details><summary>CustomPaintのコード</summary>

```dart
    return CustomPaint(
      child: ClipPath(
        clipper: _PageTurnClipper(animation: animation),
        child: child,
      ),
    );
```

```dart

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) { ... }
}
```

</details>

### めくられたページを実装する

次は画面の要素のうち、先ほどは考えなかった部分である、「ページの裏側のように見える図形」について考えましょう

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/278901/1dc931cb-5c13-bb8f-dd2c-bc7d224499d3.png" width="300">

この部分は言い換えれば「ページのめくられた部分」を表しています。
ということは顕になった「遷移先の緑色の画面」と同じ面積ですね？

また、青色の画面と緑色の境界がページの折り目であることを考えると、「ページのめくられた部分」と「遷移先の緑色の画面」は、「ページの折り目」に対して**線対称**のようです。
右上の角からページの折り目に対して垂線を引き、右上の角と垂線・折り目の交点との距離と同じ距離となる点を探せばよさそうです。

...ここから先の計算に関しては一番最後のおまけに載せておきます。

計算さえできればあとはCustomPaintで描くだけです！
CustomPaintには`child`ともう一つ、`foregroundPainter`という値を渡せるので、遷移先の画面をClipした箇所はchildに、ページのめくられた部分はforegroundPainterに置くことでページをめくるアニメーションを表現しました。

```dart
    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を算出した努力の跡
多分最初の方は計算ミスしていた気がする

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/278901/d604b9d2-3191-104c-28cc-db5f70f47fb2.png" width="400">