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
    }

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值