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

## はじめに
この記事は[CyberAgent Developers #2 Advent Calendar 2018](https://adventar.org/calendars/2953)の13日目の記事です。
この記事ではHero animationとPhotoViewというライブラリを使って作る画像ビューアの実装例を紹介します。

### 今回作るもの
下記のようなものを作ります（小さくてすみません）。

![image_viewer_sample](https://github.com/kitoko552/flutter_image_viewer_sample/raw/master/gif/readme.gif?raw=true)


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

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

## Hero animationとは
言葉で説明するのが難しいので知らない方は[この動画](https://youtu.be/CEcFnqRDfgw)あたりを見てください（QiitaってYouTube貼れないんですね）。


iOSでもたまにこのアニメーションをみかけますが、Androidの方でより使われている印象があります。
~~両OS共に実装するとなると自前orライブラリで実装すると思われますが、~~ (Androidでは標準で実装できるようです)
Flutterでは公式のWidgetとして[Hero](https://docs.flutter.io/flutter/widgets/Hero-class.html)が提供されていて実装がとても簡単です。
Heroについて詳しく知りたい方はFlutter公式の記事（[Hero Animations](https://flutter.io/docs/development/ui/animations/hero-animations)）とか動画（[Hero -Flutter Widget of the Week-](https://www.youtube.com/watch?v=Be9UH1kXFDw)）とか見ると早く理解できると思います。

## PhotoViewとは
[PhotoView](https://pub.dartlang.org/packages/photo_view)は画像のズームを可能にするWidgetを提供しているライブラリです。

![screen1.gif](https://github.com/renancaraujo/photo_view/blob/master/screen1.gif?raw=true)
(gifは https://github.com/renancaraujo/photo_view より拝借)

横スワイプで前後の画像を閲覧するギャラリー機能も提供しています。
PhotoViewの[README](https://github.com/renancaraujo/photo_view)を読むと大体何ができるかがわかります。

## 実装方法

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

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

```dart:遷移元の画像リスト.dart
Hero(
  tag: name,
  child: Image.asset(name),
)
```

```dart: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](https://medium.com/flutter-community/mastering-hero-animations-in-flutter-bc07e1bea327)を読むとよくまとまっているのでおすすめです。

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

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

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

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

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

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

```dart: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には名前の通りドラッグが始まるときに呼ぶ処理を書いています。

```dart: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にはドラッグ中に呼ばれる処理を書いています。

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

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

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

```dart: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はドラッグアニメーションの内容になります。
今回のサンプルでは、**位置**と**大きさ**をアニメーションさせています。
ドラッグ中に指についてくるように位置をアニメーションさせ、ドラッグ中に画面中央から離れていくほど大きさを小さくしています。

```dart: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の内容です。

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

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

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

```dart: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の内容は以下です。

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

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

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

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

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

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

```dart: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`という状態変化のコールバックがあるので、それをこちらで保存します。

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

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

```dart
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