Android 手把手進階自定義View(十四)- ScalableImageView

一、基礎準備


Android 手把手進階自定義View(十)- 事件分發機制解析
Android 手把手進階自定義View(十一)- 手勢檢測 GestureDetector
Android 手把手進階自定義View(十二)- 縮放手勢檢測 ScaleGestureDetector
Android 手把手進階自定義View(十三)- 滾動計算 Scroller、OverScroller

經過前四篇的學習,我們對View的觸摸反饋、手勢檢測、滑動有了一定的瞭解,這一篇我們將對這部分內容做一個實踐。

 

二、可放縮的 ImageView


我們要實現的是類似於相冊裏圖片的觸摸交互效果,不知道是什麼效果的可以打開手機相冊看一看。下面我們來一步步分析。

2.1、居中繪製原始圖片,並計算內貼邊、外貼邊的放縮倍數,初始給圖片設置爲內貼邊。

首先我們來看一下什麼是內貼邊、外貼邊,假設我們有一張圖片(200px * 200px),三種效果如下圖所示:

根據上圖我們可以知道:

  • 內貼邊:將圖片寬、高較小的邊放縮至對應的屏幕寬或高(對應圖片的方向)的大小

  • 外貼邊:將圖片寬、高較大的邊放縮至對應的屏幕寬或高(對應圖片的方向)的大小

理解它們之後對應的放縮倍數也就好計算了,代碼如下(kotlin):

    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        super.onSizeChanged(w, h, oldw, oldh)

        //這裏確定兩個使原圖達到內貼邊、外貼邊的放縮倍數,
        if (mBitmap.width / mBitmap.height.toFloat() > width / height.toFloat()) {
            mSmallScale = width / mBitmap.width.toFloat()
            mBigScale = height / mBitmap.height.toFloat()
        } else {
            mSmallScale = height / mBitmap.height.toFloat()
            mBigScale = width / mBitmap.width.toFloat()
        }
        //爲了更好的展示拖動效果,外貼邊放縮大小這裏再乘一個放大係數,使得上下左右都能夠拖動
        mBigScale *= OVER_SCALE_FACTOR
    }

 

2.2、雙擊放大/縮小

對於雙擊,我們可以用 GestureDetectorCompat.OnDoubleTapListener 方便的進行檢測。這裏有個問題需要注意,我們先看一下:

如上圖所示的黃色圈圈區域,作圖是手指點擊位置,右圖是點擊區域放大後的位置,明顯兩者相對於屏幕中心的距離變化了。造成這種效果的原因是我們設置了canvas基於屏幕中心進行放縮,我們畫個圖看一下:

上圖中,紅色是屏幕中心,兩個綠圈圈代表放縮前後的同一個點。所以爲了雙擊的點在放大後還在當前位置,我們就需要對canvas進行 translate 偏移,偏移量的計算也比較容易了,當然爲了避免偏移到空白位置(保證canvas始終在圖片內部)還需要限制偏移的最大值和最小值:

        //GestureDetector.OnDoubleTapListener
        override fun onDoubleTap(e: MotionEvent): Boolean {
            //雙擊切換放大、縮小
            mIsBigScale = !mIsBigScale
            if (mIsBigScale) {
                //計算雙擊點偏移,使雙擊的點在放大前後位於同一個點
                //雙擊點距離中心 - 放大後的這個點距離中心
                setBitmapOffsetPoint(
                    (e.x - width / 2f) - (e.x - width / 2f) * mBigScale / mSmallScale,
                    (e.y - height / 2f) - (e.y - height / 2f) * mBigScale / mSmallScale
                )
                //正向動畫
                getScaleAnimator().start()
            } else {
                //反向動畫
                getScaleAnimator().reverse()
            }
            return false
        }

    /**
     * 設置圖片偏移並檢查邊界
     */
    fun setBitmapOffsetPoint(x: Float, y: Float) {
        mCanvasOffsetPoint.x = x
        mCanvasOffsetPoint.x = Math.min(mCanvasOffsetPoint.x, mCanvasMaxOffsetPoint.x)
        mCanvasOffsetPoint.x = Math.max(mCanvasOffsetPoint.x, mCanvasMinOffsetPoint.x)
        mCanvasOffsetPoint.y = y
        mCanvasOffsetPoint.y = Math.min(mCanvasOffsetPoint.y, mCanvasMaxOffsetPoint.y)
        mCanvasOffsetPoint.y = Math.max(mCanvasOffsetPoint.y, mCanvasMinOffsetPoint.y)
    }

        //其中
        //canvas負向偏移最小值
        mCanvasMinOffsetPoint.x = -(mBitmap.width * mBigScale - width) / 2
        mCanvasMinOffsetPoint.y = -(mBitmap.height * mBigScale - height) / 2
        //canvas正向偏移最大值
        mCanvasMaxOffsetPoint.x = (mBitmap.width * mBigScale - width) / 2
        mCanvasMaxOffsetPoint.y = (mBitmap.height * mBigScale - height) / 2

沒錯,上圖中還加了放大縮小時的一個過渡動畫效果。

 

2.3、放大後可以滑動

滑動比較容易,我們直接用 GestureDetectorCompat.OnDoubleTapListener 的  onScroll 方法即可,同樣也需要對偏移的邊界做限制,代碼如下:

        override fun onScroll(down: MotionEvent, e2: MotionEvent, distanceX: Float, distanceY: Float): Boolean {
            //大圖模式時纔可以滑動
            if (mIsBigScale) {
                //限制滾動邊界
                setBitmapOffsetPoint(mCanvasOffsetPoint.x - distanceX, mCanvasOffsetPoint.y - distanceY)
                invalidate()
            }
            return false
        }

 

2.4、慣性滑動

慣性滑動也稱彈性滑動,即快速滑動後擡起手指,圖片仍能滑動一段距離。這個的話需要用到 OverScroller 來計算,我們可以通過 GestureDetectorCompat.OnDoubleTapListener 的 onFling 方法,也可以通過 onTouchEvent 中擡起手指的 UP 事件時用 VelocityTracker 計算的速度來觸發 OverScroller 的 fling。這裏我們用第一種方式,代碼如下:

        override fun onFling(e1: MotionEvent?, e2: MotionEvent?, velocityX: Float, velocityY: Float): Boolean {
            //大圖模式纔可以彈性滑動
            if (mIsBigScale) {
                mScroller.fling(
                    mCanvasOffsetPoint.x.toInt(), mCanvasOffsetPoint.y.toInt(), velocityX.toInt(), velocityY.toInt(),
                    mCanvasMinOffsetPoint.x.toInt(), mCanvasMaxOffsetPoint.x.toInt(),
                    mCanvasMinOffsetPoint.y.toInt(), mCanvasMaxOffsetPoint.y.toInt(),
                    100, 100
                )
                invalidate()
            }
            return false
        }

    override fun computeScroll() {
        //scroller用法
        if (mScroller.computeScrollOffset()) {
            mCanvasOffsetPoint.x = mScroller.currX.toFloat()
            mCanvasOffsetPoint.y = mScroller.currY.toFloat()
            invalidate()
        }
    }

 

2.5、完整代碼

看到這裏我們已經基本分析完了這樣一個效果的實現,下面我們看下完整的代碼:

class ScalableImageView(context: Context?, attrs: AttributeSet?) : View(context, attrs) {
    companion object {
        //原圖大小
        val IMAGE_WIDTH = Utils.dp2px(300)
        //放大係數
        const val OVER_SCALE_FACTOR = 2
    }

    private var mPaint = Paint(Paint.ANTI_ALIAS_FLAG)
    private var mBitmap: Bitmap
    //圖片繪製起點
    private var mBitmapStartPoint = PointF()
    //canvas當前偏移
    private var mCanvasOffsetPoint = PointF()
    //canvas負向偏移最小值
    private var mCanvasMinOffsetPoint = PointF()
    //canvas正向偏移最大值
    private var mCanvasMaxOffsetPoint = PointF()
    //是否是大圖
    private var mIsBigScale = false
    //達到內貼邊效果的放縮倍數,即小圖
    private var mSmallScale = 0f
    //達到外貼邊效果的放縮倍數,即大圖
    private var mBigScale = 0f
    private var mScroller: OverScroller
    private var mGestureDetector: GestureDetectorCompat
    private var mGestureListener = MyGestureListener()
    //屬性動畫
    private lateinit var mScaleAnimator: ObjectAnimator

    private var mScaleFraction = 0f
        set(value) {
            field = value
            invalidate()
        }

    init {
        mBitmap = Utils.decodeBitmap(resources, R.drawable.avatar, IMAGE_WIDTH.toInt())
        mGestureDetector = GestureDetectorCompat(context, mGestureListener)
        mScroller = OverScroller(context)
    }

    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        super.onSizeChanged(w, h, oldw, oldh)
        //使得圖片初始是居中的
        mBitmapStartPoint.x = (width - mBitmap.width) / 2f
        mBitmapStartPoint.y = (height - mBitmap.height) / 2f

        //這裏確定兩個使原圖達到內貼邊、外貼邊的放縮倍數,
        if (mBitmap.width / mBitmap.height.toFloat() > width / height.toFloat()) {
            mSmallScale = width / mBitmap.width.toFloat()
            mBigScale = height / mBitmap.height.toFloat()
        } else {
            mSmallScale = height / mBitmap.height.toFloat()
            mBigScale = width / mBitmap.width.toFloat()
        }
        //爲了更好的展示拖動效果,這裏再乘一個放大係數,使得上下左右都能夠拖動
        mBigScale *= OVER_SCALE_FACTOR
        //canvas負向偏移最小值
        mCanvasMinOffsetPoint.x = -(mBitmap.width * mBigScale - width) / 2
        mCanvasMinOffsetPoint.y = -(mBitmap.height * mBigScale - height) / 2
        //canvas正向偏移最大值
        mCanvasMaxOffsetPoint.x = (mBitmap.width * mBigScale - width) / 2
        mCanvasMaxOffsetPoint.y = (mBitmap.height * mBigScale - height) / 2
    }

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        //乘以scaleFraction是爲了在縮小時恢復到中點
        canvas.translate(mCanvasOffsetPoint.x * mScaleFraction, mCanvasOffsetPoint.y * mScaleFraction)
        //放縮倍數
        val scale = mSmallScale + (mBigScale - mSmallScale) * mScaleFraction
        canvas.scale(scale, scale, width / 2f, height / 2f)
        canvas.drawBitmap(mBitmap, mBitmapStartPoint.x, mBitmapStartPoint.y, mPaint)
    }

    @SuppressLint("ClickableViewAccessibility")
    override fun onTouchEvent(event: MotionEvent?): Boolean {
        return mGestureDetector.onTouchEvent(event)
    }

    override fun computeScroll() {
        //scroller用法
        if (mScroller.computeScrollOffset()) {
            mCanvasOffsetPoint.x = mScroller.currX.toFloat()
            mCanvasOffsetPoint.y = mScroller.currY.toFloat()
            invalidate()
        }
    }

    /**
     * 獲取動畫
     */
    fun getScaleAnimator(): ObjectAnimator {
        if (!this::mScaleAnimator.isInitialized) {
            mScaleAnimator = ObjectAnimator.ofFloat(this, "mScaleFraction", 0f, 1f)
        }
        return mScaleAnimator
    }

    /**
     * 設置圖片偏移並檢查邊界
     */
    fun setBitmapOffsetPoint(x: Float, y: Float) {
        mCanvasOffsetPoint.x = x
        mCanvasOffsetPoint.x = Math.min(mCanvasOffsetPoint.x, mCanvasMaxOffsetPoint.x)
        mCanvasOffsetPoint.x = Math.max(mCanvasOffsetPoint.x, mCanvasMinOffsetPoint.x)
        mCanvasOffsetPoint.y = y
        mCanvasOffsetPoint.y = Math.min(mCanvasOffsetPoint.y, mCanvasMaxOffsetPoint.y)
        mCanvasOffsetPoint.y = Math.max(mCanvasOffsetPoint.y, mCanvasMinOffsetPoint.y)
    }

    inner class MyGestureListener : GestureDetector.OnGestureListener, GestureDetector.OnDoubleTapListener {
        override fun onShowPress(e: MotionEvent?) {
        }

        override fun onSingleTapUp(e: MotionEvent?): Boolean {
            return false
        }

        override fun onDown(e: MotionEvent?): Boolean {
            //手指按下時如果還在滾動則停止滾動
            if (!mScroller.isFinished) {
                mScroller.abortAnimation()
            }
            //down事件,必須返回true,否則後面的事件就收不到了
            return true
        }

        override fun onFling(e1: MotionEvent?, e2: MotionEvent?, velocityX: Float, velocityY: Float): Boolean {
            //大圖模式纔可以彈性滑動
            if (mIsBigScale) {
                mScroller.fling(
                    mCanvasOffsetPoint.x.toInt(), mCanvasOffsetPoint.y.toInt(), velocityX.toInt(), velocityY.toInt(),
                    mCanvasMinOffsetPoint.x.toInt(), mCanvasMaxOffsetPoint.x.toInt(),
                    mCanvasMinOffsetPoint.y.toInt(), mCanvasMaxOffsetPoint.y.toInt(),
                    100, 100
                )
                invalidate()
            }
            return false
        }

        override fun onScroll(down: MotionEvent, e2: MotionEvent, distanceX: Float, distanceY: Float): Boolean {
            //大圖模式時纔可以滑動
            if (mIsBigScale) {
                //限制滾動邊界
                setBitmapOffsetPoint(mCanvasOffsetPoint.x - distanceX, mCanvasOffsetPoint.y - distanceY)
                invalidate()
            }
            return false
        }

        override fun onLongPress(e: MotionEvent?) {
        }

        override fun onDoubleTap(e: MotionEvent): Boolean {
            //雙擊切換放大、縮小
            mIsBigScale = !mIsBigScale
            if (mIsBigScale) {
                //計算雙擊點偏移,使雙擊的點在放大前後位於同一個點
                //雙擊點距離中心 - 放大後的這個點距離中心
                setBitmapOffsetPoint(
                    (e.x - width / 2f) - (e.x - width / 2f) * mBigScale / mSmallScale,
                    (e.y - height / 2f) - (e.y - height / 2f) * mBigScale / mSmallScale
                )
                //正向動畫
                getScaleAnimator().start()
            } else {
                //反向動畫
                getScaleAnimator().reverse()
            }
            return false
        }

        override fun onDoubleTapEvent(e: MotionEvent?): Boolean {
            return false
        }

        override fun onSingleTapConfirmed(e: MotionEvent?): Boolean {
            return false
        }
    }

}

 

三、雙指縮放


在上一節我們實現了一個雙擊放大、縮小的 ImageView,但是還缺了雙指縮放的效果,雙指縮放手勢我們可以通過 ScaleGestureDetector 來檢測。這一節我們來分析一下如何實現。

3.1、接管 onTouchEvent

GestureDetector、ScaleGestureDetector 都可以接管 onTouchEvent 事件,但是同一時間只能接管一個,所以我們可以根據當前是否正在進行縮放手勢來決定誰來接管。代碼如下:

    override fun onTouchEvent(event: MotionEvent?): Boolean {
        var result = mScaleGestureDetector.onTouchEvent(event)
        //isInProgress 是否正在進行縮放手勢
        if (!mScaleGestureDetector.isInProgress) {
            //根據是否正在進行縮放手勢來決定誰來接管onTouchEvent
            result = mGestureDetector.onTouchEvent(event)
        }
        return result
    }

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章