【Android & Kotlin】拡大縮小と縦横(上下左右)と斜めの慣性スクロールに対応したカスタムイメージビューを作ってみた

Android & Kotlin

マインスイーパーのゲーム画面を作りたくて、拡大縮小とかスクロールがいい感じにできる標準のビューがないかAndroid SDKを漁ってみたけどありませんでした。

ScrollViewも縦か横のスクロールだけで斜めができない中途半端さ。。

どうせ自作するなら汎用的に使えるようにして公開することにしました。

機能としては、次の通りです。

  1. 拡大縮小(ピンチイン・アウト操作)
  2. 縦横斜めのスクロール(スワイプ操作 )
  3. 端っこで跳ね返る慣性スクロール(フリック操作)
  4. 端っこで引っ張って離すと戻っていく(スワイプ 操作)
そそたた
そそたた

慣性スクロールというのは、画面をフリック(素早くスクロール)したときに指を離しても慣性が働いているように徐々に止まっていく滑らかなスクロールのことです。

ぬるぬる動くようにこだわってみました。

(๑• ̀д•́ )✧

記事に登場するドロイド君は、下記のものを使用させていただきました。

Android logo PNG image with transparent background
Android logo PNG PNG image. You can download PNG image Android logo PNG, free PNG image, Android logo PNG PNG

動作イメージ

画面全体に作成したカスタムイメージビューを配置してドロイド君の画像を設定しています。

動作端末は、Blackview A80 Pro(Android 9.0)です。

1.拡大縮小

2.縦横斜めのスクロール

3.端っこで跳ね返る慣性スクロール

4.端っこで引っ張って離すと戻っていく

ソースコード

カスタムイメージビューとして作成したSosotataImageViewクラスのソースコードを長いですがそのまま載せておきます。

ZIP圧縮したものも置いておいたので見にくければダウンロードしてください。

そそたた
そそたた

ソースコードは自由に使っていただいて構いませんが、問題が起きても責任を取れないので自己責任でお願いします。

<(_ _)>

開発環境は次の通りです。

PCMacBook Pro(2016年モデル)
IDEAndroid Studio 4.0.1
Android SDKminSdkVersion 16
targetSdkVersion 30
言語Kotlin 1.3.72

ソースコードを飛ばして読みたい方はこちら

package com.sosotata.minesweeper.ui.widgets

import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Matrix
import android.graphics.RectF
import android.util.AttributeSet
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.ScaleGestureDetector
import android.view.View
import androidx.core.view.GestureDetectorCompat
import kotlinx.coroutines.*
import kotlin.math.abs

/**
 * 画像を拡大縮小、縦横斜めの慣性スロール対応カスタムイメージビュー
 */
open class SosotataImageView : View, GestureDetector.OnGestureListener, ScaleGestureDetector.OnScaleGestureListener {
    /** 描画用ビットマップ(表示する画像をsetImageで設定する)*/
    private lateinit var mRenderBitmap: Bitmap

    /** 拡大縮小、スクロール演算用マトリクス */
    private val mRenderMatrix: Matrix = Matrix()

    /** ジェスチャーディテクター(タッチイベントからフリック、長押し、スクロール等のイベントに変換) */
    private lateinit var mGestureDetector: GestureDetectorCompat

    /** スケールジェスチャーディテクター(タッチイベントから拡大縮小のイベントに変換) */
    private lateinit var mScaleGestureDetector: ScaleGestureDetector

    /** 縮小時の限界値(拡大は無制限)*/
    private var mScaleLimit: Float = 0f

    /** スクロール時の限界値 */
    private val mTranslateLimit: RectF = RectF()

    /** フリック操作時の慣性スクロールを制御するタスクのインスタンス */
    private val mFlingTask: FlingTask = FlingTask()

    /**
     * フリック操作時の慣性スクロールを制御するタスク
     */
    inner class FlingTask {
        /** 慣性スクロール制御コルーチン */
        private lateinit var flingCoroutine: FlingCoroutine

        /**
         * フリック時の慣性スクロールを実行する
         * @param velocityX 横方向の加速度(フリックの強さとして使用する)
         * @param velocityY 縦方向の加速度(フリックの強さとして使用する)
         * @param restart 実行中に再スタートする
         * */
        fun fling(velocityX: Float, velocityY: Float, restart: Boolean) {
            if (::flingCoroutine.isInitialized) {
                if (!restart && flingCoroutine.isFling) {
                    return
                }
                flingCoroutine.cleanUp()
            }
            flingCoroutine = FlingCoroutine()
            flingCoroutine.fling(velocityX, velocityY)
        }

        /**
         * 慣性スクロール制御コルーチン
         */
        inner class FlingCoroutine {
            /** スコープ */
            private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)

            var isFling: Boolean = false

            /**
             * フリック時の慣性スクロールを実行する
             * @param velocityX 横方向の加速度(フリックの強さとして使用する)
             * @param velocityY 縦方向の加速度(フリックの強さとして使用する)
             * */
            fun fling(velocityX: Float, velocityY: Float) {
                isFling = true

                scope.launch {
                    val mtr = FloatArray(9)
                    var vx = velocityX / 60     // 初速度を少し減らす(感覚での調整)
                    var vy = velocityY / 60     // 初速度を少し減らす(感覚での調整)
                    var isOverLLimit = false    // 左座標の限界値を超えた
                    var isOverRLimit = false    // 右座標の限界値を超えた
                    var isOverTLimit = false    // 上座標の限界値を超えた
                    var isOverBLimit = false    // 座標の限界値を超えた
                    var isBeforeTurnBackX = false   // 跳ね返り前フラグ
                    var isBeforeTurnBackY = false   // 跳ね返り前フラグ
                    var stopX = 0.05f            // 移動距離がこの値より小さくなったら止める
                    var stopY = 0.05f            // 移動距離がこの値より小さくなったら止める

                    //
                    // 慣性スクロールループ
                    //
                    while (isActive) {
                        translate(vx, vy)
                        mRenderMatrix.getValues(mtr)

                        //
                        // 上下左右の限界値超えチェック
                        //
                        if (!isOverRLimit) {
                            if (mTranslateLimit.left < mtr[Matrix.MTRANS_X]) {
                                if (!isOverLLimit && !isBeforeTurnBackX) {
                                    // 左座標の限界値を超えた
                                    isOverLLimit = true
                                    isBeforeTurnBackX = true
                                }
                            }
                        }
                        if (!isOverLLimit) {
                            if (mtr[Matrix.MTRANS_X] < mTranslateLimit.right) {
                                if (!isOverLLimit && !isOverRLimit && !isBeforeTurnBackX) {
                                    // 右座標の限界値を超えた
                                    isOverRLimit = true
                                    isBeforeTurnBackX = true
                                }
                            }
                        }
                        if (!isOverBLimit) {
                            if (mTranslateLimit.top < mtr[Matrix.MTRANS_Y]) {
                                if (!isOverTLimit && !isBeforeTurnBackY) {
                                    // 上座標の限界値を超えた
                                    isOverTLimit = true
                                    isBeforeTurnBackY = true
                                }
                            }
                        }
                        if (!isOverTLimit) {
                            if (mtr[Matrix.MTRANS_Y] < mTranslateLimit.bottom) {
                                if (!isOverTLimit && !isOverBLimit && !isBeforeTurnBackY) {
                                    // 下座標の限界値を超えた
                                    isOverBLimit = true
                                    isBeforeTurnBackY = true
                                }
                            }
                        }

                        //
                        // 移動距離を減らしていく
                        // ・移動限界値以内:x0.8f
                        // ・移動限界値以上(跳ね返り前):x0.4f
                        // ・移動限界値以上(跳ね返り後):x0.9f
                        //
                        vx *= if (isOverLLimit || isOverRLimit) {
                            if (isBeforeTurnBackX) {
                                0.4f
                            } else {
                                0.9f
                            }
                        } else {
                            0.8f
                        }
                        vy *= if (isOverTLimit || isOverBLimit) {
                            if (isBeforeTurnBackY) {
                                0.4f
                            } else {
                                0.9f
                            }
                        } else {
                            0.8f
                        }

                        //
                        // 慣性スクロール停止判定
                        // 上下左右の限界値を超えていたら跳ね返りを表現するために移動距離を再設定する
                        //
                        if (abs(vx) < stopX) {
                            if ((isOverLLimit || isOverRLimit) && isBeforeTurnBackX) {
                                // 跳ね返りのため移動距離を再設定
                                vx = if (mTranslateLimit.right > 0) {
                                    mtr[Matrix.MTRANS_X] - mTranslateLimit.left
                                } else {
                                    if (isOverLLimit) {
                                        mtr[Matrix.MTRANS_X] - mTranslateLimit.left
                                    } else {
                                        mtr[Matrix.MTRANS_X] - mTranslateLimit.left - mTranslateLimit.right
                                    }
                                }
                                vx *= -0.1f
                                isBeforeTurnBackX = false
                            } else {
                                // 左右座標の限界を超えずに終了
                                vx = 0f
                            }
                        }
                        if (abs(vy) < stopY) {
                            if ((isOverTLimit || isOverBLimit) && isBeforeTurnBackY) {
                                // 跳ね返りのため移動距離を再設定
                                vy = if (mTranslateLimit.bottom > 0) {
                                    mtr[Matrix.MTRANS_Y] - mTranslateLimit.top
                                } else {
                                    if (isOverTLimit) {
                                        mtr[Matrix.MTRANS_Y] - mTranslateLimit.top
                                    } else {
                                        mtr[Matrix.MTRANS_Y] - mTranslateLimit.top - mTranslateLimit.bottom
                                    }
                                }
                                vy *= -0.1f
                                isBeforeTurnBackY = false
                            } else {
                                // 左右
                                //上下座標の限界を超えずに終了
                                vy = 0f
                            }
                        }
                        if (vx == 0f && vy == 0f) {
                            // 縦横両方とも停止の閾値内に入った時点で終了
                            break
                        }
                        Thread.sleep(16) // 短くした方が滑らか
                    }
                    isFling = false
                }
            }

            fun cleanUp() {
                scope.cancel()
            }
        }
    }

    /**
     * コンストラクタ
     */
    constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {

    }

    /**
     * 描画用ビットマップを設定する
     */
    fun setImage(bmp: Bitmap) {
        mRenderBitmap = bmp
        updateScaleLimit()
        updateTranslateLimit()
        renderBitmap()
        mFlingTask.fling(0F, 0F, true)
    }

    /**
     * 描画を更新する
     */
    fun renderBitmap() {
        invalidate()
    }

    /**
     * [android.view.View.onSizeChanged]
     */
    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        super.onSizeChanged(w, h, oldw, oldh)

        if (w > 0 && h > 0) {
            if (!::mGestureDetector.isInitialized) {
                mGestureDetector = GestureDetectorCompat(context, this)
            }
            if (!::mScaleGestureDetector.isInitialized) {
                mScaleGestureDetector = ScaleGestureDetector(context, this)
            }
            updateScaleLimit()
            updateTranslateLimit()
            renderBitmap()
            mFlingTask.fling(0F, 0F, true)
        }
    }

    /**
     * [android.view.View.onDraw]
     */
    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        if (::mRenderBitmap.isInitialized) {
            canvas.drawBitmap(mRenderBitmap, mRenderMatrix, null)
        }
    }

    /**
     * [android.view.View.onTouchEvent]
     */
    override fun onTouchEvent(e: MotionEvent): Boolean {
        mGestureDetector.onTouchEvent(e)
        mScaleGestureDetector.onTouchEvent(e)

        if (e.action == MotionEvent.ACTION_UP || e.action == MotionEvent.ACTION_CANCEL) {
            // はみ出したままになるケースの回避策
            // ・ACTION_UP:スクロールや拡大縮小の完了時
            // ・ACTION_CANCEL:2本指で操作中に3本指を追加するとACTION_CANCEL発生後にタッチイベントが止まる
            mFlingTask.fling(0F,0F, false)
        }

        return true
    }

    /**
     * [android.view.GestureDetector.OnGestureListener.onShowPress]
     */
    override fun onShowPress(e: MotionEvent?) {
    }

    /**
     * [android.view.GestureDetector.OnGestureListener.onSingleTapUp]
     */
    override fun onSingleTapUp(e: MotionEvent?): Boolean {
        return true
    }

    /**
     * [android.view.GestureDetector.OnGestureListener.onDown]
     */
    override fun onDown(e: MotionEvent?): Boolean {
        return true
    }

    /**
     * [android.view.GestureDetector.OnGestureListener.onFling]
     */
    override fun onFling(e1: MotionEvent?, e2: MotionEvent?, velocityX: Float, velocityY: Float): Boolean {
        mFlingTask.fling(velocityX, velocityY, true)
        return true
    }

    /**
     * [android.view.GestureDetector.OnGestureListener.onScroll]
     */
    override fun onScroll(e1: MotionEvent?, e2: MotionEvent?, distanceX: Float, distanceY: Float): Boolean {
        var dx: Float = -distanceX
        var dy: Float = -distanceY

        // 限界を超えたときに移動距離を縮めて(x0.3)引っ張っている感覚を表現する
        val mtr = FloatArray(9)
        mRenderMatrix.getValues(mtr)
        if (mTranslateLimit.left < mtr[Matrix.MTRANS_X]) {
            dx *= 0.3F
        } else if (mtr[Matrix.MTRANS_X] < mTranslateLimit.right) {
            dx *= 0.3F
        }

        if (mTranslateLimit.top < mtr[Matrix.MTRANS_Y]) {
            dy *= 0.3F
        } else if (mtr[Matrix.MTRANS_Y] < mTranslateLimit.bottom) {
            dy *= 0.3F
        }

        translate(dx, dy)
        return true
    }

    /**
     * [android.view.GestureDetector.OnGestureListener.onLongPress]
     */
    override fun onLongPress(e: MotionEvent?) {
    }

    /**
     * [android.view.ScaleGestureDetector.OnScaleGestureListener.onScaleBegin]
     */
    override fun onScaleBegin(e: ScaleGestureDetector?): Boolean {
        return true
    }

    /**
     * [android.view.ScaleGestureDetector.OnScaleGestureListener.onScaleEnd]
     */
    override fun onScaleEnd(detector: ScaleGestureDetector?) {
    }

    /**
     * [android.view.ScaleGestureDetector.OnScaleGestureListener.onScale]
     */
    override fun onScale(detector: ScaleGestureDetector): Boolean {
        val scale = detector.currentSpan / detector.previousSpan
        val values = FloatArray(9)
        mRenderMatrix.getValues(values)
        if (values[Matrix.MSCALE_X] * scale <= mScaleLimit) {
            // 縮小限界値で止める
            values[Matrix.MSCALE_X] = mScaleLimit
            values[Matrix.MSCALE_Y] = mScaleLimit
            mRenderMatrix.setValues(values)
        } else {
            mRenderMatrix.postScale(scale, scale, detector.focusX, detector.focusY)
        }
        updateTranslateLimit()
        renderBitmap()
        return true
    }

    /**
     * 縮小限界値を更新する
     */
    private fun updateScaleLimit() {
        if (::mRenderBitmap.isInitialized) {
            val widthLimit: Float = this.width.toFloat() / mRenderBitmap.width.toFloat()
            val heightLimit: Float = this.height.toFloat() / mRenderBitmap.height.toFloat()

            // 縮小限界値を画像の縦、横で短い方が画面サイズと一致するまでにする。
            // 最大は無限。
            mScaleLimit = if (widthLimit < heightLimit) widthLimit else heightLimit
        }
    }

    /**
     * 画像を移動する
     * @param distanceX 横方向の移動距離
     * @param distanceY 縦方向の移動距離
     */
    private fun translate(distanceX: Float, distanceY: Float) {
        val values = FloatArray(9)
        mRenderMatrix.getValues(values)
        if (values[Matrix.MSCALE_X] < mScaleLimit) {
            // 移動時に縮小限界値を超えている場合は、移動をキャンセルして縮小限界値(横幅いっぱい)に補正する。
            // 最小表示の状態で画面回転すると表示領域サイズが変わって縮小限界値を超えてしまうため。
            mRenderMatrix.setScale(mScaleLimit, mScaleLimit);
            updateTranslateLimit();
        } else {
            mRenderMatrix.postTranslate(distanceX, distanceY)
        }
        renderBitmap()
    }

    /**
     * 移動限界値を更新する
     */
    private fun updateTranslateLimit() {
        if (::mRenderBitmap.isInitialized) {
            val values = FloatArray(9)
            mRenderMatrix.getValues(values)
            val vwWidth: Float = this.width.toFloat()
            val vwHeight: Float = this.height.toFloat()
            val scaleWidth = mRenderBitmap.width.toFloat() * values[Matrix.MSCALE_X]
            val scaleHeight = mRenderBitmap.height.toFloat() * values[Matrix.MSCALE_Y]

            if (scaleWidth < vwWidth) {
                // 縮小で画面サイズを超える場合、画像が中央寄せで左右に余白ができるよう限界値を調整する
                mTranslateLimit.left = (vwWidth - scaleWidth) / 2
                mTranslateLimit.right = vwWidth - mTranslateLimit.left
            } else {
                mTranslateLimit.left = 0f
                mTranslateLimit.right = vwWidth - scaleWidth
            }
            if (scaleHeight < vwHeight) {
                // 縮小で画面サイズを超える場合、画像が中央寄せで上下に余白ができるよう限界値を調整する
                mTranslateLimit.top = (vwHeight - scaleHeight) / 2
                mTranslateLimit.bottom = vwHeight - mTranslateLimit.top
            } else {
                mTranslateLimit.top = 0f
                mTranslateLimit.bottom = vwHeight - scaleHeight
            }
        }
    }
}

使い方

SosotataImageViewクラスを継承したクラス(この例ではAndroidRobotView)を作成して、適当な場所からsetImageメソッドを呼び出して画像を登録します。

そのクラスをレイアウト用のXMLファイルに配置すればOKです。

class AndroidRobotView : SosotataImageView {

    constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
        setImage(BitmapFactory.decodeResource(context.resources, R.drawable.android_robot))
    }
}
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    
    <com.sosotata.minesweeper.ui.widgets.AndroidRobotView
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>

</androidx.constraintlayout.widget.ConstraintLayout>
そそたた
そそたた

SosotataImageViewクラスをそのまま改造しても構いません。

解説

クラス図

SosotataImageView今回作成したカスタムイメージビュー
View基底クラスのビュー
OnGestureListenerタッチイベントをスクロール、フリックイベントに変換
OnScaleGestureListenerタッチイベントを拡大縮小イベントに変換
そそたた
そそたた

クラス図は、draw.ioという無料のWebツールで作成しました。

画像の描画

invalidateメソッドでビュー全体の描画要求をして、描画更新イベントでMatrixクラスの変換行列で拡大縮小、スクロールした画像を描画します。

fun renderBitmap() {
    invalidate()
}
override fun onDraw(canvas: Canvas) {
    super.onDraw(canvas)
    if (::mRenderBitmap.isInitialized) {
        canvas.drawBitmap(mRenderBitmap, mRenderMatrix, null)
    }
}

拡大縮小(ピンチイン・アウト操作)

縮小の限界値を画像が画面にぴったり納まるようmScaleLimitに設定します。

拡大の限界値を儲けてもいいのですが今回は無制限とします。

private fun updateScaleLimit() {
    if (::mRenderBitmap.isInitialized) {
        val widthLimit: Float = this.width.toFloat() / mRenderBitmap.width.toFloat()
        val heightLimit: Float = this.height.toFloat() / mRenderBitmap.height.toFloat()

        // 縮小限界値を画像の縦、横で短い方が画面サイズと一致するまでにする。
        // 拡大は無限。
        mScaleLimit = if (widthLimit < heightLimit) widthLimit else heightLimit
    }
}

currentSpanとpreviousSpanに現在と前のピンチイン・アウト2点間の距離平均が設定されるので、「currentSpan / previousSpan」で今回の拡大縮小倍率を計算します。

MatrixクラスのpostScaleメソッドに先ほどの拡大縮小倍率と起点となる座標としてfocusX、focusYを設定することで2点間の中心で拡大縮小するイメージになります。

override fun onScale(detector: ScaleGestureDetector): Boolean {
    val scale = detector.currentSpan / detector.previousSpan
    val values = FloatArray(9)
    mRenderMatrix.getValues(values)
    if (values[Matrix.MSCALE_X] * scale <= mScaleLimit) {
        // 縮小限界値で止める
        values[Matrix.MSCALE_X] = mScaleLimit
        values[Matrix.MSCALE_Y] = mScaleLimit
        mRenderMatrix.setValues(values)
    } else {
        mRenderMatrix.postScale(scale, scale, detector.focusX, detector.focusY)
    }
    updateTranslateLimit()
    renderBitmap()
    return true
}

縦横斜めのスクロール(スワイプ操作 )

現在の拡大縮小倍率を考慮して上下左右の移動の限界値を設定します。

縮小の限界値を画像の縦、横で短い方が画面ぴったり入る値を設定している関係上、画面サイズより画像サイズが小さい場合には、中央寄せで上下左右に余白を作るように移動の限界値を設定します。

private fun updateTranslateLimit() {
    if (::mRenderBitmap.isInitialized) {
        val values = FloatArray(9)
        mRenderMatrix.getValues(values)
        val vwWidth: Float = this.width.toFloat()
        val vwHeight: Float = this.height.toFloat()
        val scaleWidth = mRenderBitmap.width.toFloat() * values[Matrix.MSCALE_X]
        val scaleHeight = mRenderBitmap.height.toFloat() * values[Matrix.MSCALE_Y]

        if (scaleWidth < vwWidth) {
            // 縮小で画面サイズを超える場合、画像が中央寄せで左右に余白ができるよう限界値を調整する
            mTranslateLimit.left = (vwWidth - scaleWidth) / 2
            mTranslateLimit.right = vwWidth - mTranslateLimit.left
        } else {
            mTranslateLimit.left = 0f
            mTranslateLimit.right = vwWidth - scaleWidth
        }
        if (scaleHeight < vwHeight) {
            // 縮小で画面サイズを超える場合、画像が中央寄せで上下に余白ができるよう限界値を調整する
            mTranslateLimit.top = (vwHeight - scaleHeight) / 2
            mTranslateLimit.bottom = vwHeight - mTranslateLimit.top
        } else {
            mTranslateLimit.top = 0f
            mTranslateLimit.bottom = vwHeight - scaleHeight
        }
    }
}

スクロールには、onScrollイベントの移動距離(distanceX、distanceY)を使用する。

移動距離は、「最後の座標 – 今回の座標」なので右方向にスワイプするとマイナス値、左方向にスワイプするとプラス値となるため、指の動きに追従してスクロールするよう符号を反転させ使用します。

また、端っこで限界を超えてスクロールした時に、引っ張っている感覚を表現するため移動限界を超えた場合のみ移動処理を0.3倍しています。

override fun onScroll(e1: MotionEvent?, e2: MotionEvent?, distanceX: Float, distanceY: Float): Boolean {
    var dx: Float = -distanceX
    var dy: Float = -distanceY

    // 限界を超えたときに移動距離を縮めて(x0.3)引っ張っている感覚を表現する
    val mtr = FloatArray(9)
    mRenderMatrix.getValues(mtr)
    if (mTranslateLimit.left < mtr[Matrix.MTRANS_X]) {
        dx *= 0.3F
    } else if (mtr[Matrix.MTRANS_X] < mTranslateLimit.right) {
        dx *= 0.3F
    }

    if (mTranslateLimit.top < mtr[Matrix.MTRANS_Y]) {
        dy *= 0.3F
    } else if (mtr[Matrix.MTRANS_Y] < mTranslateLimit.bottom) {
        dy *= 0.3F
    }

    translate(dx, dy)
    return true
}

あとは、onScrollイベント発生時にMatrixクラスのpostTranslateメソッドへ移動距離を設定することでスクロールするイメージとなります。

private fun translate(distanceX: Float, distanceY: Float) {
    val values = FloatArray(9)
    mRenderMatrix.getValues(values)
    if (values[Matrix.MSCALE_X] < mScaleLimit) {
        // 移動時に縮小限界値を超えている場合は、移動をキャンセルして縮小限界値(横幅いっぱい)に補正する。
        // 最小表示の状態で画面回転すると表示領域サイズが変わって縮小限界値を超えてしまうため。
        mRenderMatrix.setScale(mScaleLimit, mScaleLimit);
        updateTranslateLimit();
    } else {
        mRenderMatrix.postTranslate(distanceX, distanceY)
    }
    renderBitmap()
}

慣性スクロール(フリック操作)

慣性スクロールは、画面をフリック操作したときに発生するonFlingイベントの加速度(velocityX、velocityY)を基に、別スレッドで泥臭く移動距離を減らしながらスクロール処理を停止の閾値を超えるまでループすることで実現しています。

その辺の泥臭い処理は、内部クラスのFlingTaskクラスのflingメソッドにまとめています。

そそたた
そそたた

Kotlinっぽくコルーチンを使ってみましたが、Kotlinの経験値が低すぎて使い方があっているか自信ない。。

|ω・`)

慣性スクロールのループの全体像がこんな感じです。

while (コルーチンがアクティブ) {
    スクロール
    
    上下左右の限界値超えチェック

    移動距離を減らしていく

    慣性スクロール停止判定

    スリープ(16ms)
}

細かい処理は泥臭いことをチマチマやっていて、詳しく説明するのが大変なので割愛します。

興味ある方は、実際にデバッグして値の変化を追うと理解しやすいと思います。

//
// 慣性スクロールループ
//
while (isActive) {
    translate(vx, vy)
    mRenderMatrix.getValues(mtr)

    //
    // 上下左右の限界値超えチェック
    //
    if (!isOverRLimit) {
        if (mTranslateLimit.left < mtr[Matrix.MTRANS_X]) {
            if (!isOverLLimit && !isBeforeTurnBackX) {
                // 左座標の限界値を超えた
                isOverLLimit = true
                isBeforeTurnBackX = true
            }
        }
    }
    if (!isOverLLimit) {
        if (mtr[Matrix.MTRANS_X] < mTranslateLimit.right) {
            if (!isOverLLimit && !isOverRLimit && !isBeforeTurnBackX) {
                // 右座標の限界値を超えた
                isOverRLimit = true
                isBeforeTurnBackX = true
            }
        }
    }
    if (!isOverBLimit) {
        if (mTranslateLimit.top < mtr[Matrix.MTRANS_Y]) {
            if (!isOverTLimit && !isBeforeTurnBackY) {
                // 上座標の限界値を超えた
                isOverTLimit = true
                isBeforeTurnBackY = true
            }
        }
    }
    if (!isOverTLimit) {
        if (mtr[Matrix.MTRANS_Y] < mTranslateLimit.bottom) {
            if (!isOverTLimit && !isOverBLimit && !isBeforeTurnBackY) {
                // 下座標の限界値を超えた
                isOverBLimit = true
                isBeforeTurnBackY = true
            }
        }
    }

    //
    // 移動距離を減らしていく
    // ・移動限界値以内:x0.8f
    // ・移動限界値以上(跳ね返り前):x0.4f
    // ・移動限界値以上(跳ね返り後):x0.9f
    //
    vx *= if (isOverLLimit || isOverRLimit) {
        if (isBeforeTurnBackX) {
            0.4f
        } else {
            0.9f
        }
    } else {
        0.8f
    }
    vy *= if (isOverTLimit || isOverBLimit) {
        if (isBeforeTurnBackY) {
            0.4f
        } else {
            0.9f
        }
    } else {
        0.8f
    }

    //
    // 慣性スクロール停止判定
    // 上下左右の限界値を超えていたら跳ね返りを表現するために移動距離を再設定する
    //
    if (abs(vx) < stopX) {
        if ((isOverLLimit || isOverRLimit) && isBeforeTurnBackX) {
            // 跳ね返りのため移動距離を再設定
            vx = if (mTranslateLimit.right > 0) {
                mtr[Matrix.MTRANS_X] - mTranslateLimit.left
            } else {
                if (isOverLLimit) {
                    mtr[Matrix.MTRANS_X] - mTranslateLimit.left
                } else {
                    mtr[Matrix.MTRANS_X] - mTranslateLimit.left - mTranslateLimit.right
                }
            }
            vx *= -0.1f
            isBeforeTurnBackX = false
        } else {
            // 左右座標の限界を超えずに終了
            vx = 0f
        }
    }
    if (abs(vy) < stopY) {
        if ((isOverTLimit || isOverBLimit) && isBeforeTurnBackY) {
            // 跳ね返りのため移動距離を再設定
            vy = if (mTranslateLimit.bottom > 0) {
                mtr[Matrix.MTRANS_Y] - mTranslateLimit.top
            } else {
                if (isOverTLimit) {
                    mtr[Matrix.MTRANS_Y] - mTranslateLimit.top
                } else {
                    mtr[Matrix.MTRANS_Y] - mTranslateLimit.top - mTranslateLimit.bottom
                }
            }
            vy *= -0.1f
            isBeforeTurnBackY = false
        } else {
            // 左右
            //上下座標の限界を超えずに終了
            vy = 0f
        }
    }
    if (vx == 0f && vy == 0f) {
        // 縦横両方とも停止の閾値内に入った時点で終了
        break
    }
    Thread.sleep(16) // 短くした方が滑らか
}

コメント

タイトルとURLをコピーしました