[Flutter] Animationを駆使してiOSの長押しメニューを再現する

# やりたいこと
今まで若干敬遠していたFlutterのAnimationを触ってみたく、iOSの写真アプリの写真の画像を長押しした際に出てくるメニューを再現を思い立ちました。。。
※Flutter標準の`CupertinoContextMenu`Widgetを使えばあっという間に再現できますが、今回はそれは使いません。
[CupertinoContextMenu class - cupertino library - Dart API](https://api.flutter.dev/flutter/cupertino/CupertinoContextMenu-class.html)

![再現したい.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/526994/a128115e-5ea2-4fc0-f154-5fdb8070d40b.gif)

# 完成品
左が今回作成したモノ。右が`CupertinoContextMenu`を使用したモノ。
大体同じっぽく見えるのでヨシ！！🐱👉
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/526994/708fccce-1b2b-7719-bbc6-89f55b71f9ad.gif" /> <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/526994/6c258af3-0b04-0d21-3435-28ca63a2ac02.gif" />

ソースコード: https://github.com/popy1017/flutter_ios_long_tap_menu

# 再現する仕様の確認
iOS14.3の写真アプリを使って、大まかな動きを把握しました。
(字面で見てもわかりづらいと思うので実際に触ってみてください)

* 画像を長押しすると、その画像より少し大きい画像とメニュー(`DetailView`と呼びます)が前面に表示される。
* 長押しした画像がへこみ(一度小さくなり)、大きくなると共に`Hero`っぽいアニメーションで`DetailView`が表示される。
* タップした位置に応じて`DetailView`の表示位置が変わる。
    * 上の方の画像をタップした場合、`DetailView`も上の方に表示される。
    * 左の画像をタップした場合、`DetailView`のメニューも左に寄って表示される。(水平方向における画像の位置は中央)
* `DetailView`の背景は透過&ぼかし
* メニューはだんだん大きくなるようなアニメーションで開閉する。
* `DetailView`を下方向にドラッグすると、メニューが閉じる。
* `DetailView`を下方向にある程度ドラッグすると、`DetailView`が閉じる

# Step1. 画像を長押しして、拡大画像を表示させる
詳細はソースコードの`Sample1`を参照してください。
タップ判定のために、`Image`Widgetを`InkWell`Widgetでラップし、`onLongPress`を設定します。
また、`Hero`アニメーションを適用させるために、`Image`Widgetを`Hero`Widgetでラップします。

```dart:sample1.dart
class Sample1 extends StatelessWidget {
  final List<Widget> photos = List.generate(
      imageUrls.length, (int index) => _HeroPhoto(url: imageUrls[index]));

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Sample1')),
      body: GridView.count(
        crossAxisCount: 2,
        children: photos,
      ),
    );
  }
}

class _HeroPhoto extends StatelessWidget {
  _HeroPhoto({@required this.url});

  final String url;

  @override
  Widget build(BuildContext context) {
    return InkWell(
      child: Hero(
        tag: url,
        child: Image.network(
          url,
          fit: BoxFit.cover,
        ),
      ),
      onLongPress: () async {
 ~~
```
`onLongPress`の処理は以下のようになっています。
注意点としては、対応する`Hero`Widgetの`child`は同じである必要があることです。
>(厳密に同じである必要があるかはわかりませんが、以下によると対応するHeroの`child`が異なる場合、挙動がおかしくなることがあるのは仕様であり、全く同じWidgetツリーとすることが推奨されています。)
[Hero animation jumps when transitioning between Images with different fit · Issue #20510 · flutter/flutter](https://github.com/flutter/flutter/issues/20510)
また、`Hero`Widgetは異なる2つのRouteで動くので、片方の`Hero`Widgetは画面遷移先に配置する必要があります。(モーダルやダイアログは画面遷移しない(同じRouteに2つのHeroがある状態になる)ので、Heroアニメーションがうまく動きません。)

```dart:sample1.dart
      onLongPress: () async {
        await Navigator.push(
          context,
          PageRouteBuilder(
            opaque: false,
            fullscreenDialog: true,  
            barrierDismissible: true,  // 画面タップで閉じる
            barrierColor: Colors.black.withOpacity(0.5), // 背景透過
            pageBuilder: (BuildContext context, _, __) {
              return Center(
                child: Hero(
                  tag: url,
                  child: Image.network(
                    url,
                    fit: BoxFit.cover,
                  ),
                ),
              );
            },
          ),
        );
      },
```
![Hero.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/526994/b07a42b9-0a77-1eae-ff5c-69303cba824f.gif)

# Step2 バウンスアニメーション(画像がへこんで、また大きくなるアニメーション)を再現する
ソースコードの`Sample4`と`Sample5`の部分が対応します。
以下の記事を参考にさせていただきました。

* [Flutterでバウンスするボタンを実装する - Qiita](https://qiita.com/noboru_i/items/404a77d7f5383df76b8e)
* [【Flutter】拡大と縮小を繰り返すアニメーションを実装する - Qiita](https://qiita.com/youmeee/items/bc2da00af12cce38a54f)

Flutterでアニメーションを付けたい場合は、簡単に使える`AnimatedXXX`というWidgetシリーズと、カスタマイズ性のある`XXXTransition`というWidgetシリーズが使えます。各Widgetの使い方などは以下の記事がわかりやすかったです。
今回は、Widgetの大きさを変えたいので`ScaleTransition`Widgetを使用します。
(`AnimatedSize`Widgetでもできるかもしれません)

* [Flutterのお手軽にアニメーションを扱えるAnimated系Widgetをすべて紹介 | by mono  | Flutter 🇯🇵 | Medium](https://medium.com/flutter-jp/implicit-animation-b9d4b7358c28)
* [FlutterのTransition系アニメーションWidgetをすべて紹介 | by mono  | Flutter 🇯🇵 | Medium](https://medium.com/flutter-jp/transition-9c57528c84b8)

まずは、`initState`にて`AnimationController`を設定します。
`_animationController.drive`で、アニメーションカーブとスケールを指定しています。このあたりの値は適当です。
[Curves class - animation library - Dart API](https://api.flutter.dev/flutter/animation/Curves-class.html)

```dart:sample5.dart
class _Sample5State extends State<Sample5> with SingleTickerProviderStateMixin {
  double width = 100;
  double height = 100;

  AnimationController _animationController;
  Animation<double> _scale;

  @override
  void initState() {
    super.initState();

    _animationController = AnimationController(
      vsync: this,
      duration: Duration(milliseconds: 200),
    );
    _scale = _animationController
        .drive(
          CurveTween(curve: Curves.bounceOut),
        )
        .drive(
          Tween(begin: 1, end: 0.8),
        );
  }
  ~~
```
次に、LongPress発生時にアニメーションを開始させます。
アニメーションの開始は、`_animationController.forward()`で行え、`whenComplete(処理)`をつけると、そのアニメーションの終了を待ってから指定した処理を行うことができます。今回は、縮小処理が終わった後に元の大きさに戻したかったので、`whenComplete(処理)`の処理に、アニメーションを逆再生する`_animationController.reverse()`を書いています。
また、本家の動作では、このタイミングで端末が振動するようになっているので、`flutter_vibrate`パッケージを使って微振動を起こすようにしています。
[flutter_vibrate | Flutter Package](https://pub.dev/packages/flutter_vibrate)


`whenComplete()`の返り値は`Future<void>`型であるため、`await`で処理全体の終了を待つことができます。アニメーションの終了を待たないと、縮小・拡大が行われる前に画像が前面に出てきてしまうので、Step1と同じような見た目になってしまいます。

```dart:sample5.dart
  Future<void> _forwardAnimation() async {
    await _animationController.forward().whenComplete(() async {
      Vibrate.feedback(FeedbackType.medium);
      _animationController.reverse();
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(),
      body: Center(
        child: GestureDetector(
          onLongPress: () async {
            await _forwardAnimation();  // 画面遷移前にアニメーションを開始し、終了を待つ
            Navigator.push(
  ~~
```
最後に、アニメーションを適用させたいWidgetを`ScaleTransition`でラップします。

```dart:sample5.dart
  ~~
         child: Container(
            width: 100,
            height: 100,
            child: ScaleTransition(
              scale: _scale,
              child: Hero(
                tag: 'rect',
                child: Image.network(
                  imageUrls.first,
                  fit: BoxFit.cover,
                ),
              ),
            ),
          ),
  ~~
```
この段階での動作はこんな感じです。(Sample5)
![Step2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/526994/6b9c8e47-eae1-b399-67fa-59e3962a1c60.gif)

# Step3 タップした位置に応じて前面に出す画像の位置を変える
ソースコードのSample7と対応しています。

タップ位置の取得には、`GestureDetector`Widgetの`onTapDown(TapDownDetails)`を使います。
`TapDownDetails.globalPosition.dx`, `TapDownDetails.globalPosition.dy`でタップ座標を取ってこれます。

タップした位置がわかったら、次はタップした位置に応じて前面に出す画像の位置を変えます。
前面に出す画像を`Align`Widgetでラップし、位置を指定できるようにします。

```dart:sample7.dart
// 前面に出す画像Widget
class _BigImage extends StatelessWidget {
  const _BigImage({
    @required this.alignment,
    @required this.url,
  });

  final Alignment alignment;
  final String url;

  @override
  Widget build(BuildContext context) {
    return Align( // <= 追加
      alignment: alignment,
      child: Container(
        // 画像の幅を画面の幅の80%に
        width: MediaQuery.of(context).size.width * 0.8,
        child: Hero(
          tag: url,
          child: ClipRRect(
            borderRadius: BorderRadius.circular(8.0), // 画像の角を丸角に
            child: Image.network(
              url,
              fit: BoxFit.cover,
            ),
          ),
        ),
      ),
    );
  }
}
```
`Align`Widgetに指定する`alignment`によく指定するのは、`Alignment.center`や`Alignment.topLeft`などですが、`Alignment(x,y)`を指定することで細かい位置調整ができます。`Alignment(x,y)`の`x,y`の値の範囲は、`-1〜1`なので、タップ位置と画面のサイズをもとに、タップ位置を`-1〜1`に正規化します。

```dart:sample7.dart
  void _setBigImageAlignment(TapDownDetails details) {
    final Size size = MediaQuery.of(context).size; // 画面サイズ
    final double halfWidth = size.width / 2;
    final double halfHeight = size.height / 2;

    final double x = details.globalPosition.dx;
    final double y = details.globalPosition.dy;

    // Alignment(x,y)の(x,y)の値の範囲は、-1~1なので
    // 画面の幅や高さからタップ座標を-1~1に正規化
    // → Align(0,0)は中央
    _alignment = Alignment(
      (x - halfWidth) / halfWidth,
      (y - halfHeight) / halfHeight,
    );
  }
```
ここでの動作はこんな感じです。(Sample7)
![Step3.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/526994/9914dfeb-2f69-3f54-dde7-d2bb9bb058e4.gif)

# Step4 背景ぼかし
ぼかしエフェクトをかけるためには、`BackdropFilter`を使います。
ぼかし処理は結構重いので、多用は厳禁。

```dart:sample8.dart
class _BigImage extends StatelessWidget {
  const _BigImage({
    @required this.alignment,
    @required this.url,
  });

  final Alignment alignment;
  final String url;

  @override
  Widget build(BuildContext context) {
    return BackdropFilter(
      filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5),
      child: Align(
        alignment: alignment,
        child: Container(
          width: MediaQuery.of(context).size.width * 0.8,
          child: Hero(
~~
```
# Step5 アクションメニューを追加し、拡大・縮小アニメーションを追加する
Sample11の内容になります。

メニューは、`Card`Wideget、`Column`Widgetを組み合わせて複数のアクションを縦に並べるようにしました。
また、タップした位置に応じてメニューの位置を変動させたいので、こちらも`Align`Widgetでラップしています。

```dart:sample11.dart
class _ActionMenu extends StatelessWidget {
  _ActionMenu({
    this.alignment = Alignment.center,
  });

  final Alignment alignment;

  @override
  Widget build(BuildContext context) {
    return Align(
      alignment: alignment,
      child: Container(
        width: 200,
        child: Card(
          shape: RoundedRectangleBorder(
            borderRadius: BorderRadius.circular(8),
          ),
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              ListTile(
                title: Text('コピー'),
                trailing: Icon(Icons.copy),
                onTap: () {},
              ),
              ~~
              ),
            ],
          ),
        ),
      ),
    );
  }
}
```

メニューに関しても大きさに関するアニメーションを使いたいので、`ScaleTransition`で囲います。

```dart:sample11.dart
class __DetailViewState extends State<_DetailView>
    with SingleTickerProviderStateMixin {

  ~~

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      ~~
      child: BackdropFilter(
        filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5),
        child: Align(
          alignment: widget.imageAlignment,
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              AnimatedContainer(
                duration: Duration(milliseconds: 1),
                transform: photoViewTransform,
                child: _BigImage(
                    alignment: widget.imageAlignment, url: widget.imageUrl),
              ),
              Padding(
                padding: const EdgeInsets.all(8.0),
                child: ScaleTransition( // <= 追加
                  scale: _scale,
                  alignment: widget.menuAlignment,
                  child: _ActionMenu(alignment: widget.menuAlignment),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}
```
Step2と同様に`initState`でアニメーションの設定をし、`initState`の最後にアニメーションを実行してメニューを開きます。Step2とは違い、最初の大きさを0、メニューが開いた時を1としたいので、`Tween(begin: 0, end: 1)`としています。

```dart:sample11.dart
class __DetailViewState extends State<_DetailView>
    with SingleTickerProviderStateMixin {
  
  AnimationController _animationController;
  Animation<double> _scale;

  @override
  void initState() {
    super.initState();

    _animationController = AnimationController(
      vsync: this,
      duration: Duration(milliseconds: 200),
    );
    _scale = _animationController
        .drive(
          CurveTween(curve: Curves.bounceOut),
        )
        .drive(
          Tween(begin: 0, end: 1),
        );
    _openMenu();
  }
  
  TickerFuture _openMenu() {
    return _animationController.forward();
  }
```
# Step6 下方向のドラッグで`DetailView`を閉じられるようにする
こちらもソースコードのSample11に対応しています。
以下の記事が大変参考になりました。
[Hero + PhotoView + GestureDetectorでドラッグアニメーション付き画像ビューア - Qiita](https://qiita.com/kitoko552/items/6548aef075e497342783)

ドラッグを検知するためには、`GestureDetector`Widgetの`onVerticalDragStart`,`onVerticalDragUpdate`,`onVerticalDragEnd`を使います。

* onVerticalDragStart: ドラッグの開始時
* onVerticalDragUpdate: ドラッグの移動
* onVerticalDragEnd: ドラッグの終了

ドラッグ距離に応じて`DetailView`を閉じるかどうかを決めたいので、ドラッグの開始地点を保持しておいて現在のドラッグ地点と比較してドラッグ距離を算出しています。また、ドラッグ開始時にメニューを閉じるようにしています。
ドラッグ距離に応じて画像を小さくしたりしていますが、上記記事で詳しく解説されているのでそちらを参照してください。

```dart:sample11.dart
class __DetailViewState extends State<_DetailView>
    with SingleTickerProviderStateMixin {
  Offset currentDragPosition = Offset.zero;
  Offset beginningDragPosition = Offset.zero;

  ~~

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: _close,
      onVerticalDragStart: (DragStartDetails dragStartDetails) {
        beginningDragPosition = dragStartDetails.globalPosition;
        _closeMenu();
      },
      onVerticalDragUpdate: (DragUpdateDetails dragUpdateDetails) {
        setState(() {
          currentDragPosition = Offset(
            dragUpdateDetails.globalPosition.dx - beginningDragPosition.dx,
            dragUpdateDetails.globalPosition.dy - beginningDragPosition.dy,
          );
        });
      },
      onVerticalDragEnd: (DragEndDetails dragEndDetails) {
        if (currentDragPosition.distance < 150.0) {
          setState(() {
            currentDragPosition = Offset.zero;
          });
          _openMenu();
        } else {
          _close();
        }
      },
```
最終的には以下のような動作になりました。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/526994/708fccce-1b2b-7719-bbc6-89f55b71f9ad.gif" />

# 既知の課題
* 本家UI、`CupertinoContextMenu`と比べるとアニメーションのリッチさが少ない。本家UIと`CupertinoContextMenu`では`DetailView`が開いたときにビヨヨンとなる感じがする。
* 本家UI、`CupertinoContextMenu`と比べると長押しの時間が微妙に長い（気がする）
* `DetailView`の画像をドラッグしているときに、メニューのアニメーションが動かない。なのでドラッグ距離に応じてリアルタイムにメニューを開いたり閉じたりすることができていない。(いったんドラッグを止めるとアニメーションが動く。)
* メニューの開閉を繰り返していると、メニューの文字とアイコンが表示されなくなることがある。