アンドロイドで画像ビューア的なものを作る時のまとめ

ずっと iOS の開発をしていたのですが、最近 Android の開発を始めました。iOS では簡単にできていたことが、「およっ、こんなことしないといけないの！？」ということがままあるので、備忘録を兼ねてまとめていきたいと思います。今日はアンドロイドで画像ビューア的な物を作る時のコードと手順です。

作りたいのは、通常画像ビューアに存在する以下のような基本的な機能です。

* ピンチズームできること
* 画像をドラッグできること
* ズームもドラッグも、画像が画面の端に来たら止まること

![output.gif](https://qiita-image-store.s3.amazonaws.com/0/108030/6b3ca03c-926b-7055-db8c-f06211029bb8.gif)

Android のコードは全て Kotlin で書かれています。

## 手順

### ステップ 1: ピンチズーム

これは公式ドキュメント (https://developer.android.com/training/gestures/scale) 通りです。

```kotlin

    private lateinit var mScaleGestureDetector: ScaleGestureDetector
    
    override fun onCreate(savedInstanceState: Bundle?) {
　　　　 ...
        mScaleGestureDetector = ScaleGestureDetector(this, ScaleListener())
    }

    override fun onTouchEvent(event: MotionEvent): Boolean {
        mScaleGestureDetector.onTouchEvent(event)
        return true
    }
```

これで、全てのタッチイベントを `ScaleGestureDetector` で捕捉し、ズームイベントが起きた場合に `ScaleListner` インスタンスのコールバックを呼ぶ、という流れができます。

あとは、`ScaleListener` クラスを作って、ズームイベントが起きた時の挙動を定義します。

```kotlin

    private var mScaleFactor = 1.0f
    ...
    private inner class ScaleListener : ScaleGestureDetector.SimpleOnScaleGestureListener() {
        override fun onScale(detector: ScaleGestureDetector): Boolean {
            mScaleFactor *= mScaleGestureDetector.scaleFactor
            mScaleFactor = Math.max(1.0f, Math.min(mScaleFactor, 5.0f))
            mImageView.scaleX = mScaleFactor
            mImageView.scaleY = mScaleFactor

            return true
        }
    }
```

これで単純なピンチズームは完了です。

### ステップ 2: ドラッグ

```kotlin

    private lateinit var mPanGestureDetector: GestureDetectorCompat
    ...
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        ...
        mPanGestureDetector = GestureDetectorCompat(this, PanListener())
    }

    override fun onTouchEvent(event: MotionEvent): Boolean {
        ...
        mPanGestureDetector.onTouchEvent(event)

        return true
    }
```

流れはピンチズームの時と全く一緒で、パンイベントが起きたら `PanListener` のコールバックが呼ばれます。
なので、`PanListener` クラスを作って、パンイベントが起きた時の挙動を記述しておきます。

```kotlin

    private var mTranslationX = 0f
    private var mTranslationY = 0f
    ...
    private inner class PanListener : GestureDetector.SimpleOnGestureListener() {
        override fun onScroll(
            e1: MotionEvent, e2: MotionEvent,
            distanceX: Float, distanceY: Float
        ): Boolean {
            val translationX = mTranslationX - distanceX
            val translationY = mTranslationY - distanceY

            mImageView.translationX = translationX
            mImageView.translationY = translationY

            return true
        }
```

### ステップ 3: translation に制限をかける

ここまででズームとドラッグはできるようになりましたが、**これだけだと画像の範囲を超えてどんどんドラッグできてしまい、ユーザビリティに問題があります**。画像の端と画面の端が一致したら、該当方向にはそれ以上 translation しない、という風にする必要があります。

まず、`ViewTreeObserver` を使って、画像及び `ImageView` ロード時の

* イメージ自体のサイズ 
  * `mImageWidth/Height` と `mDefaultImageWidth/Height` はロード時には同じだが、`mImageWidth/Height` はズームにより後で scale する)
* ViewPort のサイズ

を取得します。

```kotlin

    private var mImageWidth = 0f
    private var mImageHeight = 0f
    private var mDefaultImageWidth = 0f
    private var mDefaultImageHeight = 0f
    private var mViewPortWidth = 0f
    private var mViewPortHeight = 0f

    override fun onCreate(savedInstanceState: Bundle?) {
        ...
        val viewTreeObserver = mImageView.viewTreeObserver
        if (viewTreeObserver.isAlive) {
            viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener {
                override fun onGlobalLayout() {
                    mImageView.viewTreeObserver.removeOnGlobalLayoutListener(this)

                    val imageAspectRatio = mImageView.drawable.intrinsicHeight.toFloat() / mImageView.drawable.intrinsicWidth.toFloat()
                    val viewAspectRatio = mImageView.height.toFloat() / mImageView.width.toFloat()

                    mDefaultImageWidth = if (imageAspectRatio < viewAspectRatio) {
                        // landscape image
                        mImageView.width.toFloat()
                    } else {
                        // Portrait image
                        mImageView.height.toFloat() / imageAspectRatio
                    }

                    mDefaultImageHeight = if (imageAspectRatio < viewAspectRatio) {
                        // landscape image
                        mImageView.width.toFloat() * imageAspectRatio
                    } else {
                        // Portrait image
                        mImageView.height.toFloat()
                    }

                    mImageWidth = mDefaultImageWidth
                    mImageHeight = mDefaultImageHeight

                    mViewPortWidth = mImageView.width.toFloat()
                    mViewPortHeight = mImageView.height.toFloat()
                }
            })
        }
    }
```

そして、translation を制限 (adjust) する以下のメソッドを定義します。

```kotlin

    private fun adjustTranslation(translationX: Float, translationY: Float) {
        val translationXMargin = Math.abs((mImageWidth - mViewPortWidth) / 2)
        val translationYMargin = Math.abs((mImageHeight - mViewPortHeight) / 2)

        if (translationX < 0) {
            mTranslationX = Math.max(translationX, -translationXMargin)
        } else {
            mTranslationX = Math.min(translationX, translationXMargin)
        }

        if (translationY < 0) {
            mTranslationY = Math.max(translationY, -translationYMargin)
        } else {
            mTranslationY = Math.min(translationY, translationYMargin)
        }

        mImageView.translationX = mTranslationX
        mImageView.translationY = mTranslationY
    }
```

イメージとしては以下のような感じ。translation で ViewPort を動かすんだけれども、動かせる大きさはその時点での画像サイズと ViewPort のサイズによって決まってきます。

![AndroidImageViewer1.png](https://qiita-image-store.s3.amazonaws.com/0/108030/417511fc-41f7-e457-d09c-aa75576624e7.png)

最後に `adjustTranslation(...)` を `onScroll`/`onScale` コールバックの最後で呼べばおしまいです。完成した `PanListener`/`ScaleListener` クラスは以下のようになります。

```kotlin
    private inner class PanListener : GestureDetector.SimpleOnGestureListener() {
        override fun onScroll(
            e1: MotionEvent, e2: MotionEvent,
            distanceX: Float, distanceY: Float
        ): Boolean {
            val translationX = mTranslationX - distanceX
            val translationY = mTranslationY - distanceY

            adjustTranslation(translationX, translationY)

            return true
        }
    }

    private inner class ScaleListener : ScaleGestureDetector.SimpleOnScaleGestureListener() {
        override fun onScale(detector: ScaleGestureDetector): Boolean {
            mScaleFactor *= mScaleGestureDetector.scaleFactor
            mScaleFactor = Math.max(1.0f, Math.min(mScaleFactor, 5.0f))
            mImageView.scaleX = mScaleFactor
            mImageView.scaleY = mScaleFactor
            mImageWidth = mDefaultImageWidth * mScaleFactor
            mImageHeight = mDefaultImageHeight * mScaleFactor

            adjustTranslation(mTranslationX, mTranslationY)

            return true
        }
    }
```

### まとめ
同様な機能を作るのに、iOS だと基本的には `UIImageView` を `UIScrollView` の上に配置して、`UIScrollViewDelegate.viewForZooming` で `UIImageView` のインスタンスを返すだけで、あとは `UIScrollView` の zoom scale に関するプロパティをちょこちょこ変えてやれば簡単に実現できます。

おんなじようにできるだろう、と思って調べてみたら、ジェスチャーのキャプチャから始めないといけないだとか言われるし、関連公式ドキュメント自体(https://developer.android.com/training/gestures/scale) どうも読みづらくて、自分なりに動くものを書きました。[ソースコードはここ](https://github.com/yfujiki/Android-ImageViewerSample) です。間違っているよ、とか、もっと簡単な方法があるよ、とか教えて頂ければ幸いです。

### おまけ
ちなみに iOS だと、これ相当の機能を作るコードはこれだけです。

```
class ViewController: UIViewController {

    @IBOutlet weak var scrollView: UIScrollView!
    @IBOutlet weak var imageView: UIImageView!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        scrollView.maximumZoomScale = 5.0
        scrollView.minimumZoomScale = 1.0
    }
}

extension ViewController: UIScrollViewDelegate {
    func viewForZooming(in scrollView: UIScrollView) -> UIView? {
        return imageView;
    }
}
```