美文网首页
支持双指缩放的ScalableImageView实现

支持双指缩放的ScalableImageView实现

作者: 王灵 | 来源:发表于2021-04-22 16:15 被阅读0次

本来是想记录下这个view一步步实现的步骤的,但是到后面发现难度不小。所以直接叙述下在各个步骤的关键技术点吧!


SVID_20210421_173451_1.gif

本文涉及的知识点:canvas图片绘制与缩放,触摸反馈,动画
实现的步骤:

  • 自定义view绘制图片
  • 双击缩放实现
  • 为缩放添加动画
  • 在放大情况下支持拖动并设置边界
  • 缩小状态时的偏移修正
  • 针对手指点击位置,实现缩放
  • 双指缩放支持
  • 两指位置计算中心点实现定点缩放

完整代码放在最后面

一、自定义view绘制图片

绘制一个图片并让它局中很容易

//图片的偏移
    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        super.onSizeChanged(w, h, oldw, oldh)
        offsetX = (width - bitmap.width) / 2f
        offsetY = (height - bitmap.height) / 2f
    }

二、双击缩放实现

首先要计算内贴边和外贴边两种情况下的缩放比

//根据图片的宽高比和屏幕的宽高比来计算缩放比
    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        super.onSizeChanged(w, h, oldw, oldh)
        // 当图片的宽高比大于屏幕的宽高比时
        if (bitmap.width / bitmap.height.toFloat() > width / height.toFloat()) {
            smallScale = width / bitmap.width.toFloat()
            bigScale = height / bitmap.height.toFloat()
        } else {
            bigScale = width / bitmap.width.toFloat()
            smallScale = height / bitmap.height.toFloat()
        }
    }
   ...
//  canvas.scale 函数来实现缩放,中心就是屏幕的中心
    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        //设置缩放比例和中心位置
        canvas.scale(bigScale, bigScale, width / 2f, height / 2f)
        ...
    }

然后获取双击事件,切换缩放比
GestureDetectorCompat

  • GestureDetector.OnGestureListener
public interface OnGestureListener {

        boolean onDown(MotionEvent e);//消费

        void onShowPress(MotionEvent e);// 是否为按下状态的回调

        boolean onSingleTapUp(MotionEvent e);// onClick的替代  就是点击监听

        boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY);//当手指发生移动时触发

        void onLongPress(MotionEvent e);//长按

        boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY);// 快速滑动
    }
  • GestureDetector.OnDoubleTapListener
        boolean onSingleTapConfirmed(MotionEvent e);//不是长按 不是双击才会触发
 
        boolean onDoubleTap(MotionEvent e);//连续按两次就触发

        boolean onDoubleTapEvent(MotionEvent e);// 双击之后的后续事件

需要用到的是OnGestureListeneronDown返回TrueOnDoubleTapListeneronDoubleTap;在GestureDetector中有一个SimpleOnGestureListener类对上面的两个接口进行了默认实现,所以我们只需要重写需要的函数就可以了

    inner class MatthewGestureDetector : GestureDetector.SimpleOnGestureListener() {

        override fun onDown(e: MotionEvent?): Boolean {
            // 消费
            return true
        }

        override fun onDoubleTap(e: MotionEvent): Boolean {
            //连续按两次就触发  
           // 修改是否是方法状态的boolean就可以
            big = !big
            invalidate()
            return true
        }
    }

设置监听

    private val matthewGestureDetector: GestureDetector.SimpleOnGestureListener by lazy { MatthewGestureDetector() }
    //GestureDetectorCompat
    private val gestureDetectorCompat = GestureDetectorCompat(context, matthewGestureDetector)
....
     //把onTouch事件交给gestureDetectorCompat处理
    override fun onTouchEvent(event: MotionEvent?): Boolean {
        return  gestureDetectorCompat.onTouchEvent(event)
    }

三、为缩放添加动画

使用ObjectAnimator.ofFloat初始值和结束值分别是smallScalebigScalle.

        override fun onDoubleTap(e: MotionEvent): Boolean {
            //连续按两次就触发
            big = !big
            if (big) {
                scaleAnnotation.start()
            } else {
                scaleAnnotation.reverse()
            }
            return true
        }

四、在放大情况下支持拖动并设置边界

支持拖动很显然就需要手势监听了。
重写onScrollMatthewGestureDetector

        override fun onScroll(
            downEvent: MotionEvent?,//按下时的事件
            currentEvent: MotionEvent?,//当前的事件
            distanceX: Float,
            distanceY: Float
        ): Boolean {
            if (big) {
                //当手指发生移动时触发
                offsetX -= distanceX
                offsetY -= distanceY
                constraintOffset()
                invalidate()
            }
            return false
        }

constraintOffset()函数是用来设置边界的

    fun constraintOffset() {
        offsetX = min(offsetX, (bitmap.width * bigScale - width) / 2)
        offsetX = max(offsetX, -(bitmap.width * bigScale - width) / 2)
        offsetY = min(offsetY, (bitmap.height * bigScale - height) / 2)
        offsetY = max(offsetY, -(bitmap.height * bigScale - height) / 2)
    }

(放大之后的图片宽度-屏幕宽度)/2.
(放大之后的图片高度-屏幕高度)/2.

偏移.png
然后在onDrawtranslate设置偏移。

currentScale为当前的缩放系数;

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        val scaleFraction = (currentScale - smallScale) / (bigScale - smallScale)//当前的动画进度
        canvas.translate(offsetX * scaleFraction, offsetY * scaleFraction)//将偏移与动画进度关联,在缩小状态是就可以消除偏移的影响
        canvas.scale(currentScale, currentScale, width / 2f, height / 2f)
        canvas.drawBitmap(bitmap, originalOffsetX, originalOffsetY, null)
    }

除了拖动,还可以实现惯性滑动;onFling还记得吧。同时还需要用到OverScroller用于实现惯性滑动的辅助器
在使用前需要先了解一下OverScrollerfling函数

    /**
     * Start scrolling based on a fling gesture. The distance traveled will
     * depend on the initial velocity of the fling.
     *
     * @param startX   就是手指快速滑动离开屏幕时的位置
     * @param startY
     * @param velocityX   手指离开时的 X Y方向速度
     * @param velocityY 
     * @param minX   对最终的滑动进行限制,不能超过边界
     * @param maxX 
     * @param minY 
     * @param maxY 
     * @param overX  弹性滑动,就是滑到边界之后不会马上停止,会超过一点然后弹回来
     * @param overY 
     */
    public void fling(int startX, int startY, int velocityX, int velocityY,
            int minX, int maxX, int minY, int maxY, int overX, int overY)

fling需要的参数都很好获得

        private var scroller = OverScroller(context)
....
        override fun onFling(
            downEvent: MotionEvent?,
            currentEvent: MotionEvent?,
            velocityX: Float,
            velocityY: Float
        ): Boolean {
            // 快速滑动
            if (big) {
                var x_ = (bitmap.width * bigScale - width) / 2
                var y_ = (bitmap.height * bigScale - height) / 2
                scroller.fling(
                    offsetX.toInt(),//开始位置
                    offsetY.toInt(),
                    velocityX.toInt(),//速度  这里有分歧。 在Scroller中 惯性滑动是没有初始速度的。可能理解为需要滑动的距离会好点
                    velocityY.toInt(),
                    -x_.toInt(),//边界线
                    x_.toInt(),
                    -y_.toInt(),
                    y_.toInt(),
                    40.dp.toInt(), //过量滑动
                    40.dp.toInt()
                )
                ViewCompat.postOnAnimation(this@ScalableImageView, matthewRunnable)
            }
            return false
        }

????这样就好了吗 ViewCompat.postOnAnimation(this@ScalableImageView, matthewRunnable)又是什么操作
当然并不是,在调用fling函数后scroller就可以根据设定的规则,随时间推移计算出对应的偏移量。有点类似插值器。所以我们需要不断的去获取最新的偏移量并更新。
定义一个runnabl对象去完成任务 matthewRunnable

    inner class MatthewRunnable : Runnable {
        override fun run() {
            if (scroller.computeScrollOffset()) {
                offsetX = scroller.currX.toFloat()
                offsetY = scroller.currY.toFloat()
                invalidate()
                ViewCompat.postOnAnimation(this@ScalableImageView, this)
            }
        }
    }

ViewCompat.postOnAnimation(View view, Runnable action)的作用是在下一帧来到时调用action里的任务,这样就优雅的完成了偏移量的不断获取与更新。还有一个问题是:这个循环调用什么时候结束。答案是scroller.computeScrollOffset()

    /**
     * Call this when you want to know the new location. If it returns true, the
     * animation is not yet finished.
     *如果你想知道新的地点,可以打这个电话。如果返回true,则动画尚未完成。
     */
    public boolean computeScrollOffset()

五、缩小状态时的偏移修正

除了在onDraw里把偏移与动画进度绑定外。在图片从放大状态到缩小状态,动画结束是也应该重置偏移。

    private var scaleAnnotation = ObjectAnimator.ofFloat(this, "currentScale", smallScale, bigScale).apply { 
       // 动画结束时
        doOnEnd { 
            if (!big){
                offsetX=0f
                offsetY=0f
            }
        }
    }

六、针对手指点击位置,实现缩放

很明显获取双击是手指的坐标,设置为缩放中心点,并做边界限制

        override fun onDoubleTap(e: MotionEvent): Boolean {
            //连续按两次就触发
            big = !big
            if (big) {
                offsetX = (e.x - width / 2) * (1 - bigScale / smallScale)
                offsetY = (e.y - height / 2) * (1 - bigScale / smallScale)
                constraintOffset()
                scaleAnnotation.start()
            } else {
                scaleAnnotation.reverse()
            }
            return true
        }

七、双指缩放支持

双指缩放的功能需要用到ScaleGestureDetector.OnScaleGestureListener

public interface OnScaleGestureListener {
         //缩放因子  根据返回值不同会有不同的表现
        // True 表示考虑当前事件   即保存这次事件 用于与下一次进行比对计算缩放比例
       // False 表示不考虑当前事件   会用上次保存的事件 与下一次进行对比计算缩放比例
        public boolean onScale(ScaleGestureDetector detector);

        // 称捏开始时调用
        public boolean onScaleBegin(ScaleGestureDetector detector);

       // 撑捏结束时调用
        public void onScaleEnd(ScaleGestureDetector detector);
    }

同样的常见对象,设置监听

    private val matthewScaleGestureListener = MatthewScaleGestureListener()
    private var scaleGestureDetector = ScaleGestureDetector(context, matthewScaleGestureListener)
....
    override fun onTouchEvent(event: MotionEvent?): Boolean {
        val result = scaleGestureDetector.onTouchEvent(event)
        return result
    }

缩放系数计算

        override fun onScale(detector: ScaleGestureDetector): Boolean {
            val tempCurrentScroller = currentScale * detector.scaleFactor
            if (tempCurrentScroller in smallScale..bigScale) {
                currentScale *= detector.scaleFactor
                return true
            } else {
                return false
            }
        }

可以直接用currentScale *= detector.scaleFactor计算,然后限制在smallScale..bigScale内,直接返回True;上面的写法对在撑捏过度时反向操作进行了优化。

八、两指位置计算中心点实现定点缩放

onScaleBegin里计算出撑捏中心点设置位置偏移,与双击的计算类似

        override fun onScaleBegin(detector: ScaleGestureDetector): Boolean {
            offsetX = (detector.focusX - width / 2) * (1 - bigScale / smallScale)
            offsetY = (detector.focusY - height / 2) * (1 - bigScale / smallScale)
            constraintOffset()
            return true
        }

完整的代码

package com.matthew.drawing.view

import android.animation.ObjectAnimator
import android.content.Context
import android.graphics.Canvas
import android.util.AttributeSet
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.ScaleGestureDetector
import android.view.View
import android.widget.OverScroller
import androidx.core.animation.doOnEnd
import androidx.core.view.GestureDetectorCompat
import androidx.core.view.ViewCompat
import kotlin.math.max
import kotlin.math.min

/**
 *Author:wangling
 *Email:wl_0420@163.com
 *Date:4/20/21 2:11 PM
 */
private const val EXTRA_SCALE_FRACTION = 1.5f

class ScalableImageView(context: Context?, attrs: AttributeSet?) : View(context, attrs) {
    private val bitmap = getAvatar(300.dp)
    private val matthewGestureDetector: GestureDetector.SimpleOnGestureListener by lazy { MatthewGestureDetector() }
    private val matthewRunnable: Runnable by lazy { MatthewRunnable() }

    // 绘制偏移
    private var offsetX = 0f
    private var offsetY = 0f

    // 原始绘制偏移
    private var originalOffsetX = 0f
    private var originalOffsetY = 0f

    //  缩放比
    private var smallScale = 0f
    private var bigScale = 0f

    //GestureDetectorCompat
    private val gestureDetectorCompat = GestureDetectorCompat(context, matthewGestureDetector)

    private var big = false


    private var currentScale = 0f
        set(value) {
            field = value
            invalidate()
        }
    private var scaleAnnotation = ObjectAnimator.ofFloat(this, "currentScale", smallScale, bigScale)

    private var scroller = OverScroller(context)
    private val matthewScaleGestureListener = MatthewScaleGestureListener()
    private var scaleGestureDetector = ScaleGestureDetector(context, matthewScaleGestureListener)

    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        super.onSizeChanged(w, h, oldw, oldh)
        originalOffsetX = (width - bitmap.width) / 2f
        originalOffsetY = (height - bitmap.height) / 2f

        if (bitmap.width / bitmap.height.toFloat() > width / height.toFloat()) {
            smallScale = width / bitmap.width.toFloat()
            bigScale = height / bitmap.height.toFloat() * EXTRA_SCALE_FRACTION
        } else {
            bigScale = width / bitmap.width.toFloat() * EXTRA_SCALE_FRACTION
            smallScale = height / bitmap.height.toFloat()
        }
        currentScale = smallScale
        scaleAnnotation.setFloatValues(smallScale, bigScale)
    }

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        val scaleFraction = (currentScale - smallScale) / (bigScale - smallScale)
        canvas.translate(offsetX * scaleFraction, offsetY * scaleFraction)
        canvas.scale(currentScale, currentScale, width / 2f, height / 2f)
        canvas.drawBitmap(bitmap, originalOffsetX, originalOffsetY, null)
    }

    override fun onTouchEvent(event: MotionEvent?): Boolean {
        val result = scaleGestureDetector.onTouchEvent(event)
        if (!scaleGestureDetector.isInProgress) {
            gestureDetectorCompat.onTouchEvent(event)
        }
        return result
    }


    fun constraintOffset() {
        offsetX = min(offsetX, (bitmap.width * bigScale - width) / 2)
        offsetX = max(offsetX, -(bitmap.width * bigScale - width) / 2)
        offsetY = min(offsetY, (bitmap.height * bigScale - height) / 2)
        offsetY = max(offsetY, -(bitmap.height * bigScale - height) / 2)
    }


    inner class MatthewGestureDetector : GestureDetector.SimpleOnGestureListener() {
//        override fun onShowPress(e: MotionEvent?) {
//            // 是否为按下状态的回调
//        }

//        override fun onSingleTapUp(e: MotionEvent?): Boolean {
//            // onClick的替代  就是点击监听
//            return false
//        }

        override fun onDown(e: MotionEvent?): Boolean {
            // 消费
            return true
        }

        override fun onFling(
            downEvent: MotionEvent?,
            currentEvent: MotionEvent?,
            velocityX: Float,
            velocityY: Float
        ): Boolean {
            // 快速滑动
            if (big) {
                var x_ = (bitmap.width * bigScale - width) / 2
                var y_ = (bitmap.height * bigScale - height) / 2
                scroller.fling(
                    offsetX.toInt(),//开始位置
                    offsetY.toInt(),
                    velocityX.toInt(),//
                    velocityY.toInt(),
                    -x_.toInt(),//边界线
                    x_.toInt(),
                    -y_.toInt(),
                    y_.toInt(),
                    40.dp.toInt(), //过量滑动
                    40.dp.toInt()
                )
                ViewCompat.postOnAnimation(this@ScalableImageView, matthewRunnable)
            }
            return false
        }

        override fun onScroll(
            downEvent: MotionEvent?,//按下时的事件
            currentEvent: MotionEvent?,//当前的事件
            distanceX: Float,
            distanceY: Float
        ): Boolean {
            if (big) {
                //当手指发生移动时触发
                offsetX -= distanceX
                offsetY -= distanceY
                constraintOffset()
                invalidate()
            }
            return false
        }

        //        override fun onLongPress(e: MotionEvent?) {
//            //长按
//        }
        override fun onDoubleTap(e: MotionEvent): Boolean {
            //连续按两次就触发
            big = !big
            if (big) {
                offsetX = (e.x - width / 2) * (1 - bigScale / smallScale)
                offsetY = (e.y - height / 2) * (1 - bigScale / smallScale)
                constraintOffset()
                scaleAnnotation.start()
            } else {
                scaleAnnotation.reverse()
            }
            return true
        }

//        override fun onDoubleTapEvent(e: MotionEvent?): Boolean {
//            // 双击之后的后续事件
//            return false
//        }
//
//        override fun onSingleTapConfirmed(e: MotionEvent?): Boolean {
//            //不是长按 不是双击才会触发
//            return false
//        }
    }

    inner class MatthewRunnable : Runnable {
        override fun run() {
            if (scroller.computeScrollOffset()) {
                offsetX = scroller.currX.toFloat()
                offsetY = scroller.currY.toFloat()
                invalidate()
                ViewCompat.postOnAnimation(this@ScalableImageView, this)
            }
        }
    }

    inner class MatthewScaleGestureListener : ScaleGestureDetector.OnScaleGestureListener {
        override fun onScaleBegin(detector: ScaleGestureDetector): Boolean {
            offsetX = (detector.focusX - width / 2) * (1 - bigScale / smallScale)
            offsetY = (detector.focusY - height / 2) * (1 - bigScale / smallScale)
            constraintOffset()
            return true
        }

        override fun onScaleEnd(detector: ScaleGestureDetector?) {
        }

        override fun onScale(detector: ScaleGestureDetector): Boolean {
            val tempCurrentScroller = currentScale * detector.scaleFactor
            if (tempCurrentScroller in smallScale..bigScale) {
                currentScale *= detector.scaleFactor
                return true
            } else {
                return false
            }
        }
    }

}


相关文章

网友评论

      本文标题:支持双指缩放的ScalableImageView实现

      本文链接:https://www.haomeiwen.com/subject/ykrnlltx.html