Help us understand the problem. What is going on with this article?

Hero + PhotoView + GestureDetectorでドラッグアニメーション付き画像ビューア

More than 1 year has passed since last update.

はじめに

この記事はCyberAgent Developers #2 Advent Calendar 2018の13日目の記事です。
この記事ではHero animationとPhotoViewというライブラリを使って作る画像ビューアの実装例を紹介します。

今回作るもの

下記のようなものを作ります(小さくてすみません)。

image_viewer_sample

画像がリストで並んでいてそれをタップすると画像ビューアにHeroで遷移し、画像ビューアではピンチやダブルタップによるズームが可能で、かつ、ドラッグで画像を移動でき、閾値以上ドラッグするとHeroで元の画像リスト画面に戻るというものです。
動作として、AndroidのGoogle Photosのアプリを意識しています。

以下にコード全体をあげているのでこちらを見ながら読むと理解しやすいと思います。
https://github.com/kitoko552/flutter_image_viewer_sample

Hero animationとは

言葉で説明するのが難しいので知らない方はこの動画あたりを見てください(QiitaってYouTube貼れないんですね)。

iOSでもたまにこのアニメーションをみかけますが、Androidの方でより使われている印象があります。
両OS共に実装するとなると自前orライブラリで実装すると思われますが、 (Androidでは標準で実装できるようです)
Flutterでは公式のWidgetとしてHeroが提供されていて実装がとても簡単です。
Heroについて詳しく知りたい方はFlutter公式の記事(Hero Animations)とか動画(Hero -Flutter Widget of the Week-)とか見ると早く理解できると思います。

PhotoViewとは

PhotoViewは画像のズームを可能にするWidgetを提供しているライブラリです。

screen1.gif
(gifは https://github.com/renancaraujo/photo_view より拝借)

横スワイプで前後の画像を閲覧するギャラリー機能も提供しています。
PhotoViewのREADMEを読むと大体何ができるかがわかります。

実装方法

Hero部分

Heroの実装をしたことがある方は分かると思いますが、Heroの実装自体は凝らなければとても簡単です。
遷移元のWidgetツリーで動かす部分のWidgetをHeroでラップしてあげてtagを設定し、 遷移先でも同じtagを設定したHeroをWidgetツリー内に入れるだけです。

今回のサンプルでは画像(asset)を動かすので、遷移元ではImage WidgetをHeroでラップします。
遷移先ではPhotoViewを使うので直接Heroは使わず、PhotoViewのheroTagフィールドを使います。

遷移元の画像リスト.dart
Hero(
  tag: name,
  child: Image.asset(name),
)
image_viewer.dart
PhotoView(
  imageProvider: AssetImage(widget.assetName),
  heroTag: widget.assetName,
)

Hero animation部分のみを抜粋するとこれだけになります。
あとはNavigator.of(context).push/popするだけでHeroアニメーションは完成です。

ちなみに、Heroをカスタマイズしたい方はMastering Hero Animations in Flutterを読むとよくまとまっているのでおすすめです。

画像ビューア部分

画像ビューア自体の実装もPhotoViewをそのまま使えばいいだけなので簡単です。

image_viewer.dart
PhotoView(
  imageProvider: AssetImage(widget.assetName),
  heroTag: widget.assetName,
  minScale: PhotoViewComputedScale.contained,
)

追加したのはminScaleだけです。
これを指定してピンチで画像を縮小ズームしていっても指を離すとcontained状態(画面いっぱいに画像がおさまっている状態)に戻るようになります。

これだけでも画像ビューアとしては十分だと思います。
ただ有名どころのサービスではスワイプやドラッグで元の画面に戻れる仕様のものが多いと思うので、今回のサンプルではこれにドラッグアニメーションをつけていきます。

ドラッグアニメーション

ドラッグアニメーションを追加するために、GestureDetectorを使います。
GestureDetectorはドラッグやタップ等のユーザーが行うジェスチャを検知し、それぞれに対応するハンドリングを定義できるWidgetです。
これを使ってドラッグを検知して、画像を指についてくるようにします。

最初に実装内容の大枠を見せると以下のようになります。

image_viewer.dart
Widget _buildImage(BuildContext context) {
  return GestureDetector(
    onVerticalDragStart: onVerticalDragStart,
    onVerticalDragUpdate: onVerticalDragUpdate,
    onVerticalDragEnd: onVerticalDragEnd,
    child: Container(
      color: Colors.black,
      child: AnimatedContainer(
        duration: Duration(milliseconds: photoViewAnimationDurationMilliSec),
        transform: photoViewTransform,
        child: PhotoView(
          imageProvider: AssetImage(widget.assetName),
          heroTag: widget.assetName,
          minScale: PhotoViewComputedScale.contained,
        ),
      ),
    ),
  );
}

それぞれの変数を説明していきます。

onVerticalDragStart

onVerticalDragStartには名前の通りドラッグが始まるときに呼ぶ処理を書いています。

image_viewer.dart
void onVerticalDragStart(DragStartDetails details) {
  setState(() {
    barsOpacity = 0.0;
    photoViewAnimationDurationMilliSec = 0;
  });
  beginningDragPosition = details.globalPosition;
}

photoViewAnimationDurationMilliSecは画像部分のアニメーションのduration(millisecond)を表しています。
ドラッグ中は指についてピッタリついてきてほいしので0をセットしています。
ドラッグが終わって(指が画面から離れて)画像をビューア内の元の位置に戻す時にもアニメーションで戻すので、その時には200をセットするようにしています。

なのでここでやっていることは、画像部分のアニメーションのdurationを0にセットし、ドラッグし始めた位置を保存する、ということになります。

onVerticalDragUpdate

onVerticalDragUpdateにはドラッグ中に呼ばれる処理を書いています。

image_viewer.dart
void onVerticalDragUpdate(DragUpdateDetails details) {
  setState(() {
    currentDragPosition = Offset(
      details.globalPosition.dx - beginningDragPosition.dx,
      details.globalPosition.dy - beginningDragPosition.dy,
    );
  });
}

ここでは、現在の指の位置を計算して保存しています。

onVerticalDragEnd

onVerticalDragEndにはドラッグが終わった時に呼ばれる処理を書いています。

image_viewer.dart
void onVerticalDragEnd(DragEndDetails details) {
  if (currentDragPosition.distance < 100.0) {
    setState(() {
      photoViewAnimationDurationMilliSec = 200;
      currentDragPosition = Offset.zero;
      barsOpacity = 1.0;
    });
  } else {
    Navigator.of(context).pop();
  }
}

ドラッグが終わった時の位置が閾値内(このサンプルでは100)なら画像を元の位置に戻し、閾値外なら前の画面にHeroで戻る、ということをしています。

photoViewTransform

photoViewTransformはドラッグアニメーションの内容になります。
今回のサンプルでは、位置大きさをアニメーションさせています。
ドラッグ中に指についてくるように位置をアニメーションさせ、ドラッグ中に画面中央から離れていくほど大きさを小さくしています。

image_viewer.dart
Matrix4 get photoViewTransform {
  // 位置
  final translationTransform = Matrix4.translationValues(
    currentDragPosition.dx,
    currentDragPosition.dy,
    0.0,
  );

  // 大きさ
  final scaleTransform = Matrix4.diagonal3Values(
    photoViewScale,
    photoViewScale,
    1.0,
  );

  return translationTransform * scaleTransform;
}

ドラッグ中に画面中央から離れていくほど大きさを小さくしていると書きましたが、ずっと小さくなられても困るので最小値を定めています。
それがphotoViewScaleの内容です。

image_viewer.dart
double get photoViewScale {
  return max(1.0 - currentDragPosition.distance * 0.001, 0.8);
}

今回のサンプルでは、大きさがx0.8よりは小さくならないようにしています。
ちなみにmax関数を使うにはdart:mathのインポートが必要です。

後ろを移動距離で透かせる

追加でちょっとだけ改善します。
ドラッグの移動距離に応じて背景色を透かせます。

image_viewer.dart
Widget _buildImage(BuildContext context) {
  return GestureDetector(
    onVerticalDragStart: onVerticalDragStart,
    onVerticalDragUpdate: onVerticalDragUpdate,
    onVerticalDragEnd: onVerticalDragEnd,
    child: Container(
      color: Colors.black.withOpacity(photoViewOpacity), // updated
      child: AnimatedContainer(
        duration: Duration(milliseconds: photoViewAnimationDurationMilliSec),
        transform: photoViewTransform,
        child: PhotoView(
          backgroundDecoration: BoxDecoration(color: Colors.transparent), // new
          imageProvider: AssetImage(widget.assetName),
          heroTag: widget.assetName,
          minScale: PhotoViewComputedScale.contained,
        ),
      ),
    ),
  );
}

背景を移動距離で変更するために、withOpacityを使ってopacityを動的にします。
photoViewOpacityの内容は以下です。

image_viewer.dart
double get photoViewOpacity {
  return max(1.0 - currentDragPosition.distance * 0.005, 0.1);
}

こちらもphotoViewScale同様、最小値を決めてそれ以下にならないように制御しています。

プラスでPhotoViewの背景を透過するためにbackgroundDecorationを追加して完成です。

ズーム時にもドラッグアニメーションが走ってしまう問題

しかしこのままだと、ズーム時にズーム箇所を変更したい時にもドラッグアニメーションが走ってしまって困ります。

これを防ぐために、ドラッグアニメーションを走らせるための条件を追加します。

image_viewer.dart
Widget _buildImage(BuildContext context) {
  return GestureDetector(
    onVerticalDragStart: scaleState == PhotoViewScaleState.initial
        ? onVerticalDragStart
        : null, // updated
    onVerticalDragUpdate: scaleState == PhotoViewScaleState.initial
        ? onVerticalDragUpdate
        : null, // updated
    onVerticalDragEnd:
        scaleState == PhotoViewScaleState.initial ? onVerticalDragEnd : null, // updated
    child: Container(
      color: Colors.black.withOpacity(photoViewOpacity),
      child: AnimatedContainer(
        duration: Duration(milliseconds: photoViewAnimationDurationMilliSec),
        transform: photoViewTransform,
        child: PhotoView(
          backgroundDecoration: BoxDecoration(color: Colors.transparent),
          imageProvider: AssetImage(widget.assetName),
          heroTag: widget.assetName,
          minScale: PhotoViewComputedScale.contained,
          scaleStateChangedCallback: (state) {
            setState(() {
              scaleState = state;
            });
          }, // new
        ),
      ),
    ),
  );
}

PhotoViewにはscaleStateChangedCallbackという状態変化のコールバックがあるので、それをこちらで保存します。

scaleStateChangedCallback: (state) {
  setState(() {
    scaleState = state;
  });
},

そして状態がinitial以外の場合はドラッグアニメーションを走らせないということをしてあげます。

onVerticalDragStart: scaleState == PhotoViewScaleState.initial
    ? onVerticalDragStart
    : null,
onVerticalDragUpdate: scaleState == PhotoViewScaleState.initial
    ? onVerticalDragUpdate
    : null,
onVerticalDragEnd:
    scaleState == PhotoViewScaleState.initial ? onVerticalDragEnd : null,

ドラッグ中に後ろの画像が見える問題

実はこの状態ではドラッグ中に後ろの画像が見える問題が残ってしまいます。
こちらもサンプルでは解決していますが、疲れたので長くなってしまうので割愛します。
簡単に言うと、画面間遷移が終わったときに、遷移元の画像のopacityをいじると解決できます。

おわりに

長々と書いてしまいましたが、コード読む方が早いと思うので上にも載せましたが再度リンク貼っておきます。
https://github.com/kitoko552/flutter_image_viewer_sample

kitoko552
Software Engineer at CyberAgent, Inc. Flutter/Dart, iOS/Swift, Node.js, Kubernetes
https://note.com/kitoko552
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away