实现双向滑动的 ScalableImageView(上)

前言

我们现在就来完成一个可以随意滑动的 ScalableImageView

准备工作

创建 Extensions.kt 文件,存放待会要用到的工具。

kotlin 复制代码
val Float.dp
    get() = TypedValue.applyDimension(
        TypedValue.COMPLEX_UNIT_DIP,
        this,
        Resources.getSystem().displayMetrics
    )

val Int.dp
    get() = this.toFloat().dp

fun getBitmap(res: Resources, @DrawableRes drawableResId: Int, targetWidth: Int): Bitmap {
    val options = BitmapFactory.Options()
    options.inJustDecodeBounds = true
    BitmapFactory.decodeResource(res, drawableResId, options)
    options.inJustDecodeBounds = false
    options.inDensity = options.outWidth
    options.inTargetDensity = targetWidth
    return BitmapFactory.decodeResource(res, drawableResId, options)
}

创建 ScalableImageView(继承自 View),并重写 onDraw 方法,居中绘制图片。

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

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

    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
    }

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        canvas.drawBitmap(image, imageLeft, imageTop, paint)
    }

}

运行效果:

实现步骤

实现图片缩放

我们可以使用 Canvas.scale() 实现缩放,缩放比也很容易得到,如果图片横向填满屏幕宽度,就是屏幕宽度 / 图片宽度;同理,图片纵向填满屏幕高度,缩放比就是屏幕高度 / 图片高度。

kotlin 复制代码
// 默认缩放
private val defaultScale = 1f

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

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

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

private var currentScaleMode = ScaleMode.ORIGINAL

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

    // ...

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

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

    val scale = when (currentScaleMode) {
        ScaleMode.ORIGINAL -> defaultScale
        ScaleMode.FIT_WIDTH -> scaleToFitWidth
        ScaleMode.FIT_HEIGHT -> scaleToFitHeight
    }
    canvas.scale(
        scale,
        scale,
        width / 2f,
        height / 2f
    )
    // ...
}

ScaleModeFIT_WIDTH 的效果:

ScaleModeFIT_HEIGHT 的效果:

实现双击缩放

我们要实现双击手势的监听,可以使用 Android 提供的 GestureDetector 来完成。

创建这个对象,并实现 GestureDetector.OnGestureListener 接口:

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

    // ...

    private val gestureDetector = GestureDetectorCompat(context, this)
    
    // ...

    override fun onDown(e: MotionEvent): Boolean {
        TODO("Not yet implemented")
    }

    override fun onFling(
        e1: MotionEvent?,
        e2: MotionEvent,
        velocityX: Float,
        velocityY: Float,
    ): Boolean {
        TODO("Not yet implemented")
    }

    override fun onLongPress(e: MotionEvent) {
        TODO("Not yet implemented")
    }

    override fun onScroll(
        e1: MotionEvent?,
        e2: MotionEvent,
        distanceX: Float,
        distanceY: Float,
    ): Boolean {
        TODO("Not yet implemented")
    }

    override fun onShowPress(e: MotionEvent) {
        TODO("Not yet implemented")
    }

    override fun onSingleTapUp(e: MotionEvent): Boolean {
        TODO("Not yet implemented")
    }

}

另外,GestureDetector 对象是监测器,需要我们手动挂载到当前 View 上,让它来处理触摸事件。

只需重写 onTouchEvent 方法,然后调用 GestureDetector.onTouchEvent(event) 方法即可。

kotlin 复制代码
override fun onTouchEvent(event: MotionEvent): Boolean {
    return gestureDetector.onTouchEvent(event)
}

我们再来完成重写的各个回调方法。

kotlin 复制代码
override fun onDown(e: MotionEvent): Boolean {
    // 消费事件
    return true
}

DOWN 事件到来时,onDown() 会被调用,我们返回 true,表示想要消费这一事件序列。如果这里返回 false,那么 onScrollonFling 等回调将都不会被触发。

kotlin 复制代码
override fun onShowPress(e: MotionEvent) {

}

这是 View 是否显示按下状态的回调,

kotlin 复制代码
override fun onSingleTapUp(e: MotionEvent): Boolean {
    return false // 返回值表示事件是否被消费
}

这是单击(抬起时)的回调,它的返回值表示是否消费了此次点击事件,返回 true 可以防止点击事件传递给父 View 处理。

kotlin 复制代码
override fun onFling(
    e1: MotionEvent?,
    e2: MotionEvent,
    velocityX: Float,
    velocityY: Float,
): Boolean {
    // 快速滑动手势的逻辑处理
    // ...
}


override fun onScroll(
    e1: MotionEvent?,
    e2: MotionEvent,
    distanceX: Float,
    distanceY: Float,
): Boolean {
    return false 
}

这两个回调是成对的,onScroll() 是手指移动的回调,e1 表示 DOWN 事件对应的触摸事件,e2 为当前的触摸事件,distanceXdistanceY 为当前事件与上一次事件之间的距离 (可以为负数,因为计算方式是旧坐标 - 新坐标)。

onFling() 是手指快速滑动后松手的回调,velocityXvelocityY 表示 X、Y 轴方向的速度,单位是像素每秒。

kotlin 复制代码
override fun onLongPress(e: MotionEvent) {
    
}

这是长按的回调。

到现在,我们都还没添加双击手势的判断,而它的判断需要添加 OnDoubleTapListener。因此,我们让当前 ScalableImageView 实现 GestureDetector.OnDoubleTapListener 接口,并重写三个方法,如下所示:

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

    // ...

    private val gestureDetector = GestureDetectorCompat(context, this).apply {
        setOnDoubleTapListener(this@ScalableImageView)
    }
    
    // ...

    override fun onDoubleTap(e: MotionEvent): Boolean {
        TODO("Not yet implemented")
    }

    override fun onDoubleTapEvent(e: MotionEvent): Boolean {
        TODO("Not yet implemented")
    }

    override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
        TODO("Not yet implemented")
    }

}

GestureDetectorCompat 的构造函数内部可以看到,如果设置的 OnGestureListener 对象实现了 OnDoubleTapListener 接口,会自动调用 setOnDoubleTapListener 方法,并传入这个对象。所以我们还可以省略 setOnDoubleTapListener(this@ScalableImageView) 代码。

我们还是来解释一下这三个回调方法,

kotlin 复制代码
override fun onDoubleTap(e: MotionEvent): Boolean {
    return false // 返回值表示事件是否被消费
}

这是双击的回调,第二次按下与第一次按下的间隔需要少于 DOUBLE_TAP_TIMEOUT (300ms),大于 DOUBLE_TAP_MIN_TIME (40ms),最短触发时间是用于防止手抖的。

kotlin 复制代码
override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
    return false // 返回值表示事件是否被消费
}

在设置了双击监听器后,第一次点击就不能轻易判断为单击,而是需要进行延迟确认,而 onSingleTapConfirmed() 就是这个单击确认的回调,它与 onDoubleTap() 不会发生冲突。

因此,在开启双击后,就不应该使用 onSingleTapUp(),因为它不准确;如果关闭双击,就不应该使用 onSingleTapConfirmed(),因为它会有一个延迟。

kotlin 复制代码
override fun onDoubleTapEvent(e: MotionEvent): Boolean {
    return false // 返回值表示事件是否被消费
}

这个回调用于接收第二次按下的事件序列。

现在,这些回调都搞明白后,我们就开始完成双击缩放图片,代码是这样的:

kotlin 复制代码
override fun onDoubleTap(e: MotionEvent): Boolean {
    switchScaleMode()
    return false
}

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

运行效果:

现在的缩放是生硬的,我们给缩放加上动画:

kotlin 复制代码
var scale = defaultScale
    set(value) {
        field = value
        invalidate()
    }

private val toFitWidthScaleAnimator: ObjectAnimator by lazy {
    ObjectAnimator.ofObject(this, "scale", FloatEvaluator(), defaultScale, scaleToFitWidth)
}

private val toFitHeightScaleAnimator: ObjectAnimator by lazy {
    ObjectAnimator.ofObject(this, "scale", FloatEvaluator(), scaleToFitWidth, scaleToFitHeight)
}

private val toDefaultScaleAnimator: ObjectAnimator by lazy {
    ObjectAnimator.ofObject(this, "scale", FloatEvaluator(), scaleToFitHeight, defaultScale)
}

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

//    val scale = when (currentScaleMode) {
//        ScaleMode.ORIGINAL -> defaultScale
//        ScaleMode.FIT_WIDTH -> scaleToFitWidth
//        ScaleMode.FIT_HEIGHT -> scaleToFitHeight
//    }
    canvas.scale(
        scale,
        scale,
        width / 2f,
        height / 2f
    )
    canvas.drawBitmap(image, imageLeft, imageTop, paint)
}

override fun onDoubleTap(e: MotionEvent): Boolean {
    switchScaleMode()
    when (currentScaleMode) {
        ScaleMode.ORIGINAL -> {
            toDefaultScaleAnimator.start()
            toFitWidthScaleAnimator.cancel()
            toFitHeightScaleAnimator.cancel()
        }

        ScaleMode.FIT_WIDTH -> {
            toFitWidthScaleAnimator.start()
            toDefaultScaleAnimator.cancel()
            toFitHeightScaleAnimator.cancel()
        }

        ScaleMode.FIT_HEIGHT -> {
            toFitHeightScaleAnimator.start()
            toDefaultScaleAnimator.cancel()
            toFitWidthScaleAnimator.cancel()
        }
    }
    return false
}

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

运行效果:

实现双向滑动

我们在 onScroll() 回调中处理图片的偏移。这里需要注意一点,因为我们的 Canvas 坐标系经过了放缩,所以在修改偏移时,也要考虑缩放比。

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

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
    )
}

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

override fun onDoubleTap(e: MotionEvent): Boolean {
    switchScaleMode()
    when (currentScaleMode) {
        ScaleMode.ORIGINAL -> {
            // 重置偏移
            extraOffsetX = 0f
            extraOffsetY = 0f

            toDefaultScaleAnimator.start()
            toFitWidthScaleAnimator.cancel()
            toFitHeightScaleAnimator.cancel()
        }

        ScaleMode.FIT_WIDTH -> {
            toFitWidthScaleAnimator.start()
            toDefaultScaleAnimator.cancel()
            toFitHeightScaleAnimator.cancel()
        }

        ScaleMode.FIT_HEIGHT -> {
            toFitHeightScaleAnimator.start()
            toDefaultScaleAnimator.cancel()
            toFitWidthScaleAnimator.cancel()
        }
    }

    return false
}

运行效果:

另外,在滑动时,会超出图片的边界,出现空白区域,我们来处理一下。

onScroll() 回调中进行修正:

kotlin 复制代码
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
    extraOffsetX = min(extraOffsetX, (image.width * scale - width) / 2)
    extraOffsetX = max(extraOffsetX, -(image.width * scale - width) / 2)
    extraOffsetY -= distanceY
    extraOffsetY = min(extraOffsetY, (image.height * scale - height) / 2)
    extraOffsetY = max(extraOffsetY, -(image.height * scale - height) / 2)

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

这样就行了。

实现惯性滑动

惯性滑动需要用到之前说到的 onFling() 回调。 那么惯性滑动过程中的位移该怎么计算?我们可以通过 OverScroller 来完成。

kotlin 复制代码
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(),
    )
    return false
}

知道惯性滑动的物理模型的话,以上这些参数应该不难填。然后我们可以调用 computeScrollOffset() 方法,获取当前时刻的计算结果。

因为在惯性滑动的过程中,并没有相应的回调。所以需要我们手动不断获取计算结果,修改属性值进行动画,像这样:

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

    processFling()

    return false
}

/**
 * 执行惯性滑动动画
 */
fun processFling() {
    // computeScrollOffset 返回 false 表示动画结束
    if (!overScroller.computeScrollOffset()){ // 计算当前滑动位置
        // 递归结束
        return
    }

    extraOffsetX = overScroller.currX.toFloat()
    extraOffsetY = overScroller.currY.toFloat()
    invalidate()

    // 在下一帧继续执行,形成动画
    postOnAnimation {
        processFling()
    }
}

另外,因为使用 Lambda 会不断创建对象,消耗性能。最佳实践是让当前 ScalableImageView 实现 Runnable 接口,然后将当前对象传入即可。

kotlin 复制代码
class ScalableImageView(context: Context, attrs: AttributeSet?) : View(context, attrs),
    GestureDetector.OnGestureListener, GestureDetector.OnDoubleTapListener, Runnable {
    // ...

    /**
     * 执行惯性滑动动画
     */
    fun processFling() {
        // ...

        postOnAnimation(this)
    }


    override fun run() {
        processFling()
    }
    
    // ...
}

其实 fling() 还有一个重载方法,我们可以额外填入 overXoverY 参数,这两个参数表示过度滑动的偏移量。

例如快速向上滑动微信、QQ 的聊天列表,列表滑到底时,并不是立马停止的,而是会有一个回弹效果。

我们简单设置一下:

kotlin 复制代码
// 惯性滑动
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(),
    50.dp.toInt(),
    0 
)

运行效果:

我们还可以使用 Scroller 来进行惯性滑动,不过 OverScrollerScroller 的升级版,增加了对边界回弹(Overscroll)和精细的物理效果,我们更多会使用 OverScroller

而且 Scroller 惯性滑动时,一定不会超出边界,导致即使很用力滑动,滑动速度也并不会增加,使用它可能会降低用户交互体验。

相关推荐
Y4090015 小时前
数据库基础知识——聚合函数、分组查询
android·数据库
没有了遇见10 小时前
Android 原生定位(替代高德 / 百度等三方定位)<终极版本>
android
2501_9160088911 小时前
iOS 抓包工具有哪些?全面盘点主流工具与功能对比分析
android·ios·小程序·https·uni-app·iphone·webview
2501_9159214311 小时前
iOS混淆工具实战 视频流媒体类 App 的版权与播放安全保护
android·ios·小程序·https·uni-app·iphone·webview
CYRUS_STUDIO12 小时前
LLVM 全面解析:NDK 为什么离不开它?如何亲手编译调试 clang
android·编译器·llvm
CYRUS_STUDIO12 小时前
静态分析神器 + 动态调试利器:IDA Pro × Frida 混合调试实战
android·逆向
g_i_a_o_giao14 小时前
Android8 binder源码学习分析笔记(一)
android·java·笔记·学习·binder·安卓源码分析
翻滚丷大头鱼14 小时前
android 四大组件—BroadcastReceiver
android
人生游戏牛马NPC1号15 小时前
学习 Android (二十) 学习 OpenCV (五)
android·opencv·学习