实现双向滑动的 ScalableImageView(下)

前言

在上一篇博客中,我们已经实现了 ScalableImageView 的双击缩放,以及双向滑动。

现在我们来完成缩放跟随触摸点,并解决恢复成默认缩放时图片跳动的问题,最后完成双指捏撑缩放功能。

实现过程

解决图片跳动

我们之前把重置偏移的操作放在了双击后目标 ScaleModeScaleMode.ORIGINAL 的分支中。如果图片正处于放大且有偏移的状态,那么在双击恢复时,会让图片偏移瞬间变为 0,导致看起来像是跳了一下。

我们只需不让偏移瞬间改变,让它以动画的形式改变即可。

kotlin 复制代码
var extraOffsetX = 0f
var extraOffsetY = 0f

override fun onDoubleTap(e: MotionEvent): Boolean {
    switchScaleMode()
    // ...
    
    if (currentScaleMode == ScaleMode.ORIGINAL){
        // 使用动画重置偏移
        ObjectAnimator.ofFloat(this, "extraOffsetX", extraOffsetX, 0f).start()
        ObjectAnimator.ofFloat(this, "extraOffsetY", extraOffsetY, 0f).start()
    }
    return false
}

另外,还有一个跳动问题:我们当前的 ObjectAnimator 对象的起始值和结束值是固定的。在缩放的动画过程中,如果用户再次双击,那么之前的动画会被取消,新的动画会从一个固定的预设值开始,这也会导致跳动。

所以,我们应该以当前的实时 scale 值作为动画的起点,动态地创建动画。

kotlin 复制代码
override fun onDoubleTap(e: MotionEvent): Boolean {
    // 记录动画开始前的状态
    val oldScale = scale
    val oldExtraOffsetX = extraOffsetX
    val oldExtraOffsetY = extraOffsetY

    switchScaleMode()

    val targetScale = getCurrentScale()

    // 从当前实时 scale 值开始动画
    getFloatAnimator(this, "scale", oldScale, targetScale).start()

    if (currentScaleMode == ScaleMode.ORIGINAL) {
        // 从当前实时偏移值开始动画
        getFloatAnimator(this, "extraOffsetX", oldExtraOffsetX, 0f).start()
        getFloatAnimator(this, "extraOffsetY", oldExtraOffsetY, 0f).start()
    }

    return false
}


/**
 * 获取Float属性动画
 */
private fun getFloatAnimator(
    target: Any,
    properName: String,
    startValue: Float,
    endValue: Float,
): ObjectAnimator {
    return ObjectAnimator.ofFloat(target, properName, startValue, endValue)
}

/**
 * 获取当前缩放比
 */
fun getCurrentScale(): Float =
    when (currentScaleMode) {
        ScaleMode.ORIGINAL -> {
            defaultScale
        }

        ScaleMode.FIT_WIDTH -> {
            scaleToFitWidth
        }

        ScaleMode.FIT_HEIGHT -> {
            scaleToFitHeight
        }
    }

实现缩放跟随触摸点

现在的缩放的轴心固定在了视图中心,并不会跟随触摸点,例如:

触摸点的位置在缩放后,并不与触摸点重合。我们要实现它们重合(缩放跟随),只需添加一个额外的偏移量来抵消这个视觉位移即可。

kotlin 复制代码
override fun onDoubleTap(e: MotionEvent): Boolean {
    // 记录动画开始前的状态
    val oldScale = scale
    val oldExtraOffsetX = extraOffsetX
    val oldExtraOffsetY = extraOffsetY
    
    // 切换模式
    switchScaleMode()
    
    // 目标缩放值
    val targetScale = getCurrentScale()

    // 计算围绕触摸点缩放所需的偏移量变化
    val offsetXChange = (e.x - width / 2f) * (1 - targetScale / oldScale)
    val offsetYChange = (e.y - height / 2f) * (1 - targetScale / oldScale)

    // 目标偏移 = 原始偏移 + 变化量
    var targetExtraOffsetX = oldExtraOffsetX + offsetXChange
    var targetExtraOffsetY = oldExtraOffsetY + offsetYChange

    // 特殊处理:如果回到原始状态或是FIT_WIDTH状态,重置目标偏移量
    if (currentScaleMode == ScaleMode.ORIGINAL || currentScaleMode == ScaleMode.FIT_WIDTH) {
        targetExtraOffsetX = 0f
        targetExtraOffsetY = 0f
    }

    // 从当前实时 scale 值开始动画
    getFloatAnimator(this, "scale", oldScale, targetScale).start()

    // 从当前实时偏移值开始动画
    getFloatAnimator(this, "extraOffsetX", oldExtraOffsetX, targetExtraOffsetX).start()
    getFloatAnimator(this, "extraOffsetY", oldExtraOffsetY, targetExtraOffsetY).start()

    return false
}

现在的放大效果就会很跟手了。不过还有问题没解决,设置了这个偏移后,可能会导致图片超界,出现空白区域。

我们还要对它做边界修正。我们把这个修正过程抽取成一个 fixOffset 方法。

kotlin 复制代码
override fun onDoubleTap(e: MotionEvent): Boolean {
    // 记录动画前的初始状态
    val oldScale = scale
    val oldExtraOffsetX = extraOffsetX
    val oldExtraOffsetY = extraOffsetY

    // 切换模式并获取目标缩放值
    switchScaleMode()
    val targetScale = getCurrentScale()

    // 计算目标偏移量
    var targetExtraOffsetX = oldExtraOffsetX - (e.x - width / 2f) * (targetScale / oldScale - 1)
    var targetExtraOffsetY = oldExtraOffsetY - (e.y - height / 2f) * (targetScale / oldScale - 1)

    //  对目标偏移量进行边界修正
    val maxExtraOffsetX = (image.width * targetScale - width).coerceAtLeast(0f) / 2f
    val maxExtraOffsetY = (image.height * targetScale - height).coerceAtLeast(0f) / 2f
    val (fixedX, fixedY) = fixOffset(targetExtraOffsetX, targetExtraOffsetY, maxExtraOffsetX, maxExtraOffsetY)
    targetExtraOffsetX = fixedX
    targetExtraOffsetY = fixedY
    
    // 特殊处理:如果回到原始或FIT_WIDTH状态,重置目标偏移量
    if (currentScaleMode == ScaleMode.ORIGINAL || currentScaleMode == ScaleMode.FIT_WIDTH) {
        targetExtraOffsetX = 0f
        targetExtraOffsetY = 0f
    }
    
    // 统一启动所有动画
    getFloatAnimator(this, "extraOffsetX", oldExtraOffsetX, targetExtraOffsetX).start()
    getFloatAnimator(this, "extraOffsetY", oldExtraOffsetY, targetExtraOffsetY).start()
    getFloatAnimator(this, "scale", oldScale, targetScale).start()

    return true
}

/**
 * 修正偏移
 */
private fun fixOffset(
    offsetX: Float,
    offsetY: Float,
    maxExtraOffsetX: Float,
    maxExtraOffsetY: Float,
): Pair<Float, Float> {
    val offsetXFixed = offsetX.coerceIn(-maxExtraOffsetX, maxExtraOffsetX)
    val offsetYFixed = offsetY.coerceIn(-maxExtraOffsetY, maxExtraOffsetY)
    return offsetXFixed to offsetYFixed
}

重构代码

在实现双指捏撑缩放之前,我们先来重构一下 ScalableImageView

目前 ScalableImageView 直接实现了多个接口,类中代码有些混乱,我们可以将每个接口的实现都抽取到一个个内部类中,让结构更加清晰。

Runable 为例:

kotlin 复制代码
private val processFlingRunnable = ProcessFlingRunnable()

override fun onFling(...): Boolean {
    // ...

    processFlingRunnable.processFling()
    return false
}

/**
 * 执行惯性滑动动画
 */
inner class ProcessFlingRunnable : Runnable {
    fun processFling() {
        if (!overScroller.computeScrollOffset()) {
            return
        }
        extraOffsetX = overScroller.currX.toFloat()
        extraOffsetY = overScroller.currY.toFloat()
        invalidate()
        postOnAnimation(this)
    }

    override fun run() {
        processFling()
    }
}

GestureDetector.OnGestureListener, GestureDetector.OnDoubleTapListener 的实现可以通过继承 GestureDetector.SimpleOnGestureListener 类来合并,因为它实现了这两个接口。

完整代码如下:

kotlin 复制代码
class ScalableImageView(context: Context, attrs: AttributeSet?) : View(context, attrs) {

    private val paint = Paint(Paint.ANTI_ALIAS_FLAG)

    private val imageSize = 200.dp

    var imageLeft: Float = 0f

    var imageTop: Float = 0f


    enum class ScaleMode {
        ORIGINAL,       // 原始大小
        FIT_WIDTH,      // 适应宽度
        FIT_HEIGHT      // 适应高度
    }

    private val image: Bitmap by lazy {
        getBitmap(resources, R.drawable.avator, imageSize.toInt())
    }

    // 默认缩放
    private val defaultScale = 1f

    // 水平宽度填满容器的缩放比
    private var scaleToFitWidth = 0f

    // 垂直高度填满容器的缩放比
    private var scaleToFitHeight = 0f


    private var currentScaleMode = ScaleMode.ORIGINAL
    private val gestureListener = GestureListener()
    private val processFlingRunnable = ProcessFlingRunnable()
    private val gestureDetector = GestureDetectorCompat(context, gestureListener).apply {
        setOnDoubleTapListener(gestureListener)
    }

    // 缩放
    var scale = defaultScale
        set(value) {
            field = value
            invalidate()
        }

    // 额外偏移
    var extraOffsetX = 0f
    var extraOffsetY = 0f


    private val overScroller = OverScroller(context)


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

        // 调整图片位置
        imageLeft = (width - imageSize) / 2
        imageTop = (height - imageSize) / 2

        // 调整缩放比
        scaleToFitWidth = width / imageSize
        scaleToFitHeight = height / imageSize
    }

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

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)

        canvas.scale(
            scale,
            scale,
            width / 2f,
            height / 2f
        )

        canvas.drawBitmap(
            image,
            imageLeft + extraOffsetX / scale,
            imageTop + extraOffsetY / scale,
            paint
        )
    }


    /**
     * 修正偏移
     */
    private fun fixOffset(
        offsetX: Float,
        offsetY: Float,
        maxExtraOffsetX: Float,
        maxExtraOffsetY: Float,
    ): Pair<Float, Float> {
        val offsetXFixed = offsetX.coerceIn(-maxExtraOffsetX, maxExtraOffsetX)
        val offsetYFixed = offsetY.coerceIn(-maxExtraOffsetY, maxExtraOffsetY)
        return offsetXFixed to offsetYFixed
    }


    /**
     * 切换缩放模式
     */
    fun switchScaleMode() {
        currentScaleMode = when (currentScaleMode) {
            ScaleMode.ORIGINAL -> ScaleMode.FIT_WIDTH
            ScaleMode.FIT_WIDTH -> ScaleMode.FIT_HEIGHT
            ScaleMode.FIT_HEIGHT -> ScaleMode.ORIGINAL
        }
    }

    /**
     * 获取当前缩放比
     */
    fun getCurrentScale(): Float =
        when (currentScaleMode) {
            ScaleMode.ORIGINAL -> {
                defaultScale
            }

            ScaleMode.FIT_WIDTH -> {
                scaleToFitWidth
            }

            ScaleMode.FIT_HEIGHT -> {
                scaleToFitHeight
            }
        }


    /**
     * 获取Float属性动画
     */
    private fun getFloatAnimator(
        target: Any,
        properName: String,
        startValue: Float,
        endValue: Float,
    ): ObjectAnimator {
        return ObjectAnimator.ofFloat(target, properName, startValue, endValue)
    }


    /**
     * 执行惯性滑动动画
     */
    inner class ProcessFlingRunnable : Runnable {
        /**
         * 执行惯性滑动动画
         */
        fun processFling() {
            if (!overScroller.computeScrollOffset()) {
                return
            }
            extraOffsetX = overScroller.currX.toFloat()
            extraOffsetY = overScroller.currY.toFloat()
            invalidate()

            postOnAnimation(this)
        }

        override fun run() {
            processFling()
        }
    }

    /**
     * 双击、滑动、惯性滑动手势监听器
     */
    inner class GestureListener : GestureDetector.SimpleOnGestureListener() {

        private val target = this@ScalableImageView

        override fun onDown(e: MotionEvent): Boolean {
            // 停止惯性滑动
            overScroller.forceFinished(true)

            // 消费事件
            return true
        }

        override fun onFling(
            e1: MotionEvent?,
            e2: MotionEvent,
            velocityX: Float,
            velocityY: Float,
        ): Boolean {
            // 默认缩放和FIT_WIDTH缩放下,禁止滑动
            if (currentScaleMode == ScaleMode.ORIGINAL || currentScaleMode == ScaleMode.FIT_WIDTH) {
                return false
            }
            // 惯性滑动
            overScroller.fling(
                extraOffsetX.toInt(), // 起始位置,也是起始偏移
                extraOffsetY.toInt(),
                velocityX.toInt(), // 起始速度
                velocityY.toInt(),
                (-(image.width * scale - width) / 2f).toInt(), // 滑动的边界
                ((image.width * scale - width) / 2f).toInt(),
                (-(image.height * scale - height) / 2f).toInt(),
                ((image.height * scale - height) / 2f).toInt(),
            )

            processFlingRunnable.processFling()

            return false
        }


        override fun onScroll(
            e1: MotionEvent?,
            e2: MotionEvent,
            distanceX: Float,
            distanceY: Float,
        ): Boolean {
            // 默认缩放和FIT_WIDTH缩放下,禁止滑动
            if (currentScaleMode == ScaleMode.ORIGINAL ||
                currentScaleMode == ScaleMode.FIT_WIDTH
            ) {
                return false
            }

            // 当前缩放比为scaleToFitHeight
            // 修正边界
            extraOffsetX -= distanceX
            extraOffsetY -= distanceY
            fixOffset(
                extraOffsetX, extraOffsetY,
                (image.width * scale - width).coerceAtLeast(0f) / 2,
                (image.height * scale - height).coerceAtLeast(0f) / 2
            ).apply {
                extraOffsetX = first
                extraOffsetY = second
            }


            // 记得刷新
            invalidate()
            return false
        }


        override fun onDoubleTap(e: MotionEvent): Boolean {
            // 动画前的初始状态
            val oldScale = scale
            val oldExtraOffsetX = extraOffsetX
            val oldExtraOffsetY = extraOffsetY

            // 切换缩放模式
            switchScaleMode()
            // 目标缩放值
            val targetScale = getCurrentScale()

            // 目标偏移量
            var targetExtraOffsetX =
                oldExtraOffsetX - (e.x - width / 2f) * (targetScale / oldScale - 1)
            var targetExtraOffsetY =
                oldExtraOffsetY - (e.y - height / 2f) * (targetScale / oldScale - 1)

            // 边界修正,确保最大偏移量不小于0
            val maxExtraOffsetX = (image.width * targetScale - width).coerceAtLeast(0f) / 2f
            val maxExtraOffsetY = (image.height * targetScale - height).coerceAtLeast(0f) / 2f
            fixOffset(
                targetExtraOffsetX,
                targetExtraOffsetY,
                maxExtraOffsetX,
                maxExtraOffsetY
            ).apply {
                targetExtraOffsetX = first
                targetExtraOffsetY = second
            }

            // 特殊处理:如果回到原始状态或是FIT_WIDTH状态,重置目标偏移量
            if (currentScaleMode == ScaleMode.ORIGINAL || currentScaleMode == ScaleMode.FIT_WIDTH) {
                targetExtraOffsetX = 0f
                targetExtraOffsetY = 0f
            }

            // 统一启动动画
            getFloatAnimator(target, "extraOffsetX", oldExtraOffsetX, targetExtraOffsetX).start()
            getFloatAnimator(target, "extraOffsetY", oldExtraOffsetY, targetExtraOffsetY).start()
            getFloatAnimator(target, "scale", oldScale, targetScale).start()

            return true
        }

    }

}

双指缩放

双指缩放需要用到 ScaleGestureDetector

kotlin 复制代码
class ScalableImageView(context: Context, attrs: AttributeSet?) : View(context, attrs) {
    // ...
    
    private val scaleGestureListener = ScaleListener()
    private val scaleGestureDetector = ScaleGestureDetector(context, scaleGestureListener)
    
    @SuppressLint("ClickableViewAccessibility")
    override fun onTouchEvent(event: MotionEvent): Boolean {
        scaleGestureDetector.onTouchEvent(event)
        
        // 如果缩放检测器没有处理事件,再把事件给双击/拖动检测器
        if (!scaleGestureDetector.isInProgress) {
            gestureDetector.onTouchEvent(event)
        }
        return true
    }
    
    inner class ScaleListener : ScaleGestureDetector.SimpleOnScaleGestureListener() {
        // 捏撑进行中的回调
        override fun onScale(detector: ScaleGestureDetector): Boolean {
            return true
        }
        
        // 捏撑开始的回调
        override fun onScaleBegin(detector: ScaleGestureDetector): Boolean {
            // 表示消费事件
            return true
        }

        // 捏撑结束的回调
        override fun onScaleEnd(detector: ScaleGestureDetector) {
            return
        }

    }
    
}

注意:ScaleGestureDetectorCompat 并不是 ScaleGestureDetector 的兼容版本。

接下来我们来完成 onScale() 回调。

调用 ScaleGestureDetector.getScaleFactor() 可以得到一个缩放比例,表示了相对缩放开始时的缩放系数。如果方法返回 true,表示此次缩放结束,缩放比例会重置为 1f

也就是说,如果该回调一直返回 false,那么 ScaleGestureDetector.getScaleFactor() 代表的一直是相对于缩放开始时的缩放系数;如果一直返回 true,则始终代表了相对于上一个事件的缩放比例。

我们只需这样即可:

kotlin 复制代码
inner class ScaleListener : ScaleGestureDetector.SimpleOnScaleGestureListener() {
    var initialScale = 0f

    override fun onScale(detector: ScaleGestureDetector): Boolean {
        scale = initialScale * detector.scaleFactor

        // 添加缩放的边界限制
        val maxScale = scaleToFitHeight * 2f
        val minScale = defaultScale * 0.5f
        scale = scale.coerceIn(minScale, maxScale)
        invalidate()
        
        return false
    }

    override fun onScaleBegin(detector: ScaleGestureDetector): Boolean {
        initialScale = scale
        // 表示消费事件
        return true
    }

    override fun onScaleEnd(detector: ScaleGestureDetector) {
        return
    }

}

现在缩放并不跟手,和之前类似,只需加上一个偏移量即可。缩放中心点可以通过 ScaleGestureDetector.getFocusX()ScaleGestureDetector.getFocusY() 获取。

kotlin 复制代码
override fun onScale(detector: ScaleGestureDetector): Boolean {
    val oldScale = scale
    // 计算新的目标缩放值
    var targetScale = initialScale * detector.scaleFactor
    // 添加缩放的边界限制
    val maxScale = scaleToFitHeight * 2f
    val minScale = defaultScale * 0.5f
    targetScale = targetScale.coerceIn(minScale, maxScale)

    // 计算偏移量
    val offsetXChange = (detector.focusX - width / 2f) * (1 - targetScale / oldScale)
    val offsetYChange = (detector.focusY - height / 2f) * (1 - targetScale / oldScale)

    // 应用新的缩放值和偏移量
    scale = targetScale
    extraOffsetX += offsetXChange
    extraOffsetY += offsetYChange

    // 修正边界,防止移出屏幕
    val maxExtraOffsetX = (image.width * scale - width).coerceAtLeast(0f) / 2f
    val maxExtraOffsetY = (image.height * scale - height).coerceAtLeast(0f) / 2f
    val (fixedX, fixedY) = fixOffset(
        extraOffsetX,
        extraOffsetY,
        maxExtraOffsetX,
        maxExtraOffsetY
    )
    extraOffsetX = fixedX
    extraOffsetY = fixedY

    invalidate()
    return false
}

引入双指缩放后,为了能够切换到双击缩放状态,并增加对手动模式滑动的支持。

需要修改一下之前的代码:

kotlin 复制代码
class ScalableImageView(context: Context, attrs: AttributeSet?) : View(context, attrs) {
    enum class ScaleMode {
        ORIGINAL,       // 原始大小
        FIT_WIDTH,      // 适应宽度
        FIT_HEIGHT,      // 适应高度

        // 手动缩放
        MANUAL
    }
    
    private var lastScaleMode = ScaleMode.ORIGINAL


    /**
     * 切换缩放模式
     */
    fun switchScaleMode() {
        currentScaleMode = when (currentScaleMode) {
            ScaleMode.ORIGINAL -> ScaleMode.FIT_WIDTH
            ScaleMode.FIT_WIDTH -> ScaleMode.FIT_HEIGHT
            ScaleMode.FIT_HEIGHT -> ScaleMode.ORIGINAL
            else -> ScaleMode.entries[lastScaleMode.ordinal]
        }
    }
    
    /**
     * 获取当前缩放比
     */
    fun getCurrentScale(): Float =
        when (currentScaleMode) {
            ScaleMode.ORIGINAL -> {
                defaultScale
            }

            ScaleMode.FIT_WIDTH -> {
                scaleToFitWidth
            }

            ScaleMode.FIT_HEIGHT -> {
                scaleToFitHeight
            }

            ScaleMode.MANUAL -> {
                scale
            }
        }
        

    /**
     * 双击、滑动、惯性滑动手势监听器
     */
    inner class GestureListener : GestureDetector.SimpleOnGestureListener() {

        // ...

        override fun onFling(
            e1: MotionEvent?,
            e2: MotionEvent,
            velocityX: Float,
            velocityY: Float,
        ): Boolean {
            // 默认缩放和FIT_WIDTH缩放下,禁止滑动
            // ...

            if (currentScaleMode == ScaleMode.MANUAL && scale <= scaleToFitWidth) {
                return false
            }

            val maxExtraOffsetX = (image.width * scale - width).coerceAtLeast(0f) / 2f
            val maxExtraOffsetY = (image.height * scale - height).coerceAtLeast(0f) / 2f


            // 惯性滑动
            overScroller.fling(
                extraOffsetX.toInt(), // 起始位置
                extraOffsetY.toInt(),
                velocityX.toInt(), // 起始速度
                velocityY.toInt(),
                (-maxExtraOffsetX).toInt(), // 滑动的边界
                maxExtraOffsetX.toInt(),
                (-maxExtraOffsetY).toInt(),
                maxExtraOffsetY.toInt()
            )

            // 启动动画
            postOnAnimation(processFlingRunnable)

            return false
        }


        override fun onScroll(
            e1: MotionEvent?,
            e2: MotionEvent,
            distanceX: Float,
            distanceY: Float,
        ): Boolean {
            // 默认缩放和FIT_WIDTH缩放下,禁止滑动
            // ...

            if (currentScaleMode == ScaleMode.MANUAL && scale <= scaleToFitWidth) {
                return false
            }

            // ...
            
            return false
        }
    }

    
    
    inner class ScaleListener : ScaleGestureDetector.SimpleOnScaleGestureListener() {
        override fun onScaleBegin(detector: ScaleGestureDetector): Boolean {
            initialScale = scale
            lastScaleMode =
                if (currentScaleMode == ScaleMode.MANUAL) lastScaleMode else currentScaleMode
            currentScaleMode = ScaleMode.MANUAL
            // 表示消费事件
            return true
        }
    }
}

至此,一个可缩放 ImageView 就完成了。

相关推荐
峥嵘life3 小时前
Android Studio新版本编译release版本apk实现
android·ide·android studio
studyForMokey6 小时前
【Android 消息机制】Handler
android
敲代码的鱼哇6 小时前
跳转原生系统设置插件 支持安卓/iOS/鸿蒙UTS组件
android·ios·harmonyos
翻滚丷大头鱼6 小时前
android View详解—动画
android
我是好小孩6 小时前
[Android]RecycleView的item用法
android
胖虎16 小时前
Android Studio 读取本地文件(以 ZIP 为例)
android·ide·android studio·本地文件·读取本地文件
出海小纸条6 小时前
Google Play 跨应用脚本漏洞(Cross-App Scripting)
android
小孔龙6 小时前
Android Runtime(ART) GC 日志手册
android
袁美丽..6 小时前
Android --- SystemUI 导入Android Studio及debug
android·ide·android studio