LoginSignup
7
6

More than 3 years have passed since last update.

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

Posted at

やりたいこと

今まで若干敬遠していたFlutterのAnimationを触ってみたく、iOSの写真アプリの写真の画像を長押しした際に出てくるメニューを再現を思い立ちました。。。
※Flutter標準のCupertinoContextMenuWidgetを使えばあっという間に再現できますが、今回はそれは使いません。
CupertinoContextMenu class - cupertino library - Dart API

再現したい.gif

完成品

左が今回作成したモノ。右がCupertinoContextMenuを使用したモノ。
大体同じっぽく見えるのでヨシ!!🐱👉

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

再現する仕様の確認

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

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

Step1. 画像を長押しして、拡大画像を表示させる

詳細はソースコードのSample1を参照してください。
タップ判定のために、ImageWidgetをInkWellWidgetでラップし、onLongPressを設定します。
また、Heroアニメーションを適用させるために、ImageWidgetをHeroWidgetでラップします。

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の処理は以下のようになっています。
注意点としては、対応するHeroWidgetのchildは同じである必要があることです。

(厳密に同じである必要があるかはわかりませんが、以下によると対応するHeroのchildが異なる場合、挙動がおかしくなることがあるのは仕様であり、全く同じWidgetツリーとすることが推奨されています。)
Hero animation jumps when transitioning between Images with different fit · Issue #20510 · flutter/flutter
また、HeroWidgetは異なる2つのRouteで動くので、片方のHeroWidgetは画面遷移先に配置する必要があります。(モーダルやダイアログは画面遷移しない(同じRouteに2つのHeroがある状態になる)ので、Heroアニメーションがうまく動きません。)

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

Step2 バウンスアニメーション(画像がへこんで、また大きくなるアニメーション)を再現する

ソースコードのSample4Sample5の部分が対応します。
以下の記事を参考にさせていただきました。

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

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

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

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

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でラップします。

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

Step3 タップした位置に応じて前面に出す画像の位置を変える

ソースコードのSample7と対応しています。

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

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

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,
            ),
          ),
        ),
      ),
    );
  }
}

AlignWidgetに指定するalignmentによく指定するのは、Alignment.centerAlignment.topLeftなどですが、Alignment(x,y)を指定することで細かい位置調整ができます。Alignment(x,y)x,yの値の範囲は、-1〜1なので、タップ位置と画面のサイズをもとに、タップ位置を-1〜1に正規化します。

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

Step4 背景ぼかし

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

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の内容になります。

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

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で囲います。

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)としています。

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

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

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

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

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();
        }
      },

最終的には以下のような動作になりました。

既知の課題

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

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
6