ずっと iOS の開発をしていたのですが、最近 Android の開発を始めました。iOS では簡単にできていたことが、「およっ、こんなことしないといけないの!?」ということがままあるので、備忘録を兼ねてまとめていきたいと思います。今日はアンドロイドで画像ビューア的な物を作る時のコードと手順です。
作りたいのは、通常画像ビューアに存在する以下のような基本的な機能です。
- ピンチズームできること
- 画像をドラッグできること
- ズームもドラッグも、画像が画面の端に来たら止まること
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/Height
とmDefaultImageWidth/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 のサイズによって決まってきます。
最後に 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 だと基本的には UIImageView
を UIScrollView
の上に配置して、UIScrollViewDelegate.viewForZooming
で UIImageView
のインスタンスを返すだけで、あとは 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;
}
}