实现双向滑动的 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 就完成了。

相关推荐
张小潇31 分钟前
AOSP15 Input专题InputDispatcher源码分析
android
TT_Close34 分钟前
【Flutter×鸿蒙】debug 包也要签名,这点和 Android 差远了
android·flutter·harmonyos
Kapaseker2 小时前
2026年,我们还该不该学编程?
android·kotlin
雨白18 小时前
Android 快捷方式实战指南:静态、动态与固定快捷方式详解
android
hqk18 小时前
鸿蒙项目实战:手把手带你实现 WanAndroid 布局与交互
android·前端·harmonyos
LING19 小时前
RN容器启动优化实践
android·react native
恋猫de小郭21 小时前
Flutter 发布官方 Skills ,Flutter 在 AI 领域再添一助力
android·前端·flutter
Kapaseker1 天前
一杯美式搞懂 Any、Unit、Nothing
android·kotlin
黄林晴1 天前
你的 Android App 还没接 AI?Gemini API 接入全攻略
android
恋猫de小郭2 天前
2026 Flutter VS React Native ,同时在 AI 时代 VS Native 开发,你没见过的版本
android·前端·flutter