19
17

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Last updated at Posted at 2018-12-01

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

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

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

output.gif

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

手順

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

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


    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 クラスを作って、ズームイベントが起きた時の挙動を定義します。


    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: ドラッグ


    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 クラスを作って、パンイベントが起きた時の挙動を記述しておきます。


    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/HeightmDefaultImageWidth/Height はロード時には同じだが、mImageWidth/Height はズームにより後で scale する)
  • ViewPort のサイズ

を取得します。


    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) する以下のメソッドを定義します。


    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

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

    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 だと基本的には UIImageViewUIScrollView の上に配置して、UIScrollViewDelegate.viewForZoomingUIImageView のインスタンスを返すだけで、あとは UIScrollView の zoom scale に関するプロパティをちょこちょこ変えてやれば簡単に実現できます。

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

おまけ

ちなみに 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;
    }
}
19
17
3

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
19
17

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?