阅读 1170

自己造轮子--玩转手势控制图片

前言

图片查看是日常会用到的一个功能,伴随着这个功能还有就是对图片的缩放,拖动等一些操作,这些操作则需要开发者自己去实现。目前现有的库[chrisbanes/PhotoView]就能满足一定的需求,不过如果自己再手动实现一遍的话就能满足自己客制化的需求以及相关知识的学习。

思路

手势的监听主要通过ScaleGestureDetectorGestureDetector来解决。具体针对图片的控制则是依赖ImageView.ScaleType.MATRIX,通过对矩阵的控制来显示图片,主要的控制内容为双指缩放、拖动、双击缩放。

动手

解决图片加载的问题

这里采用的是[coil]来加载网络图片,所以提一下相关的内容。 coil的基本使用非常简单,参考官方提供的文档可以完成简单的图片加载。这里主要提一些参数的配置。

imageView.load("image") {
    crossfade(200) //图片加载成功后淡入显示图片
    size(OriginalSize) //加载原图,不配置这个参数的话,coil会按ImageView的大小对图片进行适配
}
复制代码

这里还需要一个自定义的Transformation,这里其实可以窥到coil设计的巧妙之处,它拥有一个bitmap池,保证同样大小的btimap不会被多次创建浪费资源(看coil源码赶紧安排上)。

如果不自定义Transformation的话,最终创建的bitmap可能是和ImageView的大小一样(个人推断,未曾实际尝试过),所以决定直接自定义Transformation来创建与原图同样大小的bitmap来满足后续图片的操作。

class MyTransformation : Transformation {
    override fun key(): String {
        return javaClass.name
    }

    override suspend fun transform(pool: BitmapPool, input: Bitmap, size: Size): Bitmap {
        if (size !is OriginalSize) throw  Exception("should be original size")
        return pool.get(input.width, input.height, input.config).applyCanvas {
            drawBitmap(input, 0f, 0f, null)
        }
    }
}
复制代码

最终加载图片的代码如下

imageView.load("image") {
    crossfade(200) 
    size(OriginalSize) 
    transformations(MyTransformation())
}
复制代码

预备工作

新建一个对象并在初始化的时候为持有的ImageView对象配置一些参数,其中包括设置scaleTypeOnTouchListenerOnLayoutChangeListener,需要实现View.OnTouchListenerView.OnLayoutChangeListener

init {
    mImageView.scaleType = ImageView.ScaleType.MATRIX
    mImageView.setOnTouchListener(this)
    mImageView.addOnLayoutChangeListener(this)
}
复制代码

图片加载完成后缩放适配

这里主要是个人的一些需求以及对应的一些解决方案,实际情况请按个人需求做出相应的调整。 如果只想在ImageView上简单地用上手势操作,可以考虑直接使用[chrisbanes/PhotoView]这个库。

这里主要放弃了ImageView的几种适配方式,采用了以下自定的几种适配模式

enum class StartType {
    NONE,       //居中显示整张图片,类似ImageView的FIT_CENTER
    TOP,        //宽度会缩放到与View宽度一致,此时高度未达到View的高度时图片居中显示,否则顶部对齐
    LEFT,       //高度会缩放到与View高度一致,此时宽度未达到View的宽度时图片居中显示,否则按左边对齐
    RIGHT,      //高度会缩放到与View高度一致,此时宽度未达到View的宽度时图片居中显示,否则按右边对齐
    AUTO_LEFT,  //TOP与LEFT的结合
    AUTO_RIGHT  //TOP与RIGHT的结合
}
复制代码

实现接口View.OnLayoutChangeListener中的onLayoutChange方法,在该方法中依照上面的几种模式初始化一个Bitmap的初始矩阵来定义Bitmap的初始位置。

首先计算bitmap与view之间的宽比例和高比例,当中顺带初始化初始矩阵。

val drawable = mImageView.drawable ?: return
val viewWidth = mImageView.width  //view的宽度
val viewHeight = mImageView.height  //view的高度
val sourceWidth = drawable.intrinsicWidth  //源图宽度
val sourceHeight = drawable.intrinsicHeight  //源图高度
mBaseMatrix.reset()  //初始化矩阵
val widthRatio = viewWidth.toFloat() / sourceWidth.toFloat()  //宽度比例
val heightRatio = viewHeight.toFloat() / sourceHeight.toFloat()  //高度比例
复制代码
  • StartType.NONE

这种适配模式比较简单,直接选择较小的那个比例为整张图片的缩放比例,并且计算x方向的偏移量

scale = if (widthRatio < heightRatio) widthRatio else heightRatio
translateX = (viewWidth - sourceWidth * scale) / 2
复制代码
  • StartType.TOP

这种模式只需要按照宽度缩放即可,x方向上的偏移量也为0

scale = widthRatio
translateX = 0
复制代码
  • StartType.LEFT

这种模式只需要按照高度缩放即可,在bitmap的宽度经过缩放后无法占满View时计算居中时x的偏移量,否则x偏移量为0靠左对齐

scale = heightRatio
translateX = ((viewWidth - sourceWidth * scale) / 2).coerceAtLeast(0f)
复制代码
  • StartType.RIGHT

这种模式大体同上,只不过在计算x的偏移量的时候要注意靠右对齐

scale = heightRatio
with((viewWidth - sourceWidth * scale) / 2){
    translateX = if (this < 0) this * 2 else this
}
复制代码
  • StartType.AUTO_LEFT

这个模式则与之前的宽高适配相反,选择较大的那个,并提供宽度偏移

scale = if (widthRatio < heightRatio) heightRatio else widthRatio
translateX = ((viewWidth - sourceWidth * scale) / 2).coerceAtLeast(0f)
复制代码
  • StartType.AUTO_RIGHT
scale = if (widthRatio < heightRatio) heightRatio else widthRatio
translateX = if (scale == widthRatio) 0f else viewWidth - sourceWidth * scale
复制代码

最后统一计算y方向上的偏移、为初始矩阵配置bitmap缩放和偏移

val translateY: Float = ((viewHeight - sourceHeight * scale) / 2).coerceAtLeast(0f)
mBaseMatrix.postScale(scale, scale)
mBaseMatrix.postTranslate(translateX, translateY)
mImageView.imageMatrix = finalMatrix
复制代码

关于finalMatrix简单提一下

private val finalMatrix: Matrix
    get() = mDrawMatrix.apply {
        set(mBaseMatrix)
        postConcat(mDecoMatrix)
    }
复制代码

图片双指缩放

双指缩放主要通过实现OnScaleGestureListeneronScale方法来回调,这里暂定最大放大为原来的三倍,默认显示的是最小大小。

这里通过两个扩展方法提供矩阵当前的缩放

private fun Matrix.getScale(): Float {
    return sqrt(
        getValue(Matrix.MSCALE_X).toDouble().pow(2.0)
                + getValue(Matrix.MSKEW_Y).toDouble().pow(2.0)
    ).toFloat()
}
private fun Matrix.getValue(key: Int): Float {
    getValues(mMatrixValues)
    return mMatrixValues[key]
}
复制代码
  • 双指放大
if (scaleFactor > 1f && currentScale < MAX_SCALE) {
    if (currentScale * scaleFactor > MAX_SCALE) {
        mDecoMatrix.postScale(
            MAX_SCALE / currentScale, MAX_SCALE / currentScale,
            focusX, focusY
        )
    } else {
        mDecoMatrix.postScale(scaleFactor, scaleFactor, focusX, focusY)
    }
}
复制代码
  • 双指缩小
if (scaleFactor < 1f && currentScale > MIN_SCALE) {
    if (currentScale * scaleFactor < MIN_SCALE) {
        mDecoMatrix.postScale(
            MIN_SCALE / currentScale, MIN_SCALE / currentScale, focusX, focusY
        )
    } else {
        mDecoMatrix.postScale(scaleFactor, scaleFactor, focusX, focusY)
    }
}
复制代码

图片拖动

图片拖动通过OnGestureListener的方法onScroll来回调。这里先为矩阵增加了一个扩展方法来计算当前显示的矩形位置

private fun Matrix.getDisplayRect() {
    val drawable: Drawable = mImageView.drawable ?: return
    mDisplayRect.set(
        0f,
        0f,
        drawable.intrinsicWidth.toFloat(),
        drawable.intrinsicHeight.toFloat()
    )
    mapRect(mDisplayRect)
}
复制代码
override fun onScroll(
    e1: MotionEvent?, e2: MotionEvent?, distanceX: Float, distanceY: Float
): Boolean {
    var handled = false
    val viewWidth = mImageView.width
    val viewHeight = mImageView.height
    finalMatrix.getDisplayRect()
    if (distanceX > 0) {
        if (mDisplayRect.right > viewWidth) handled = true
    } else {
        if (mDisplayRect.left < 0f) handled = true
    }
    if (distanceY > 0) {
        if (mDisplayRect.bottom > viewHeight) handled = true
    } else {
        if (mDisplayRect.top < 0f) handled = true
    }
    if (handled) {
    	//这里需要对事件进行拦截,例如在ViewPager中保证优先处理图片的拖动
        mImageView.parent?.requestDisallowInterceptTouchEvent(true)
        mDecoMatrix.postTranslate(-distanceX, -distanceY)
        updateImageMatrix()
    } else {
        mImageView.parent?.requestDisallowInterceptTouchEvent(false)
    }
    return handled
}
复制代码

图片拖动fling处理

在手快速拖动后离开屏幕是会产生一个Fling让滑动逐渐停下而不是突然停下,Fling需要用RunnableOverScroller来处理。

OnGestureListeneronFling回调会在手指离开屏幕后给出两个方向的速度

override fun onFling(
    e1: MotionEvent?, e2: MotionEvent?, velocityX: Float, velocityY: Float
): Boolean {
    //velocityX < 0 手指向左滑动
    //velocityY > 0 手指向右滑动
    //velocityY > 0 手指向下滑动
    //velocityY < 0 手指向上滑动
    mCurrentFlingRunnable.fling(-velocityX.roundToInt(), -velocityY.roundToInt())
    mImageView.post(mCurrentFlingRunnable)
    return true
}
复制代码

FlingAnimation来处理两个滑动方向的速度,根据当前图片的位置计算出fling相关的参数并通过OverScroller来执行fling。

private inner class FlingAnimation : Runnable {
    private val mScroller: OverScroller = OverScroller(mImageView.context)
    private var mCurrentX: Int = 0
    private var mCurrentY: Int = 0
    
    //
    fun fling(velocityX: Int, velocityY: Int) {
        finalMatrix.getDisplayRect()
        val viewWidth = mImageView.width
        val viewHeight = mImageView.height
        val (startX: Int, startY: Int) =
            -mDisplayRect.left.roundToInt() to -mDisplayRect.top.roundToInt()
        mCurrentX = startX
        mCurrentY = startY
        /*
         * Minimum and maximum scroll positions. The minimum scroll
         * position is generally zero and the maximum scroll position
         * is generally the content size less the screen size. So if the
         * content width is 1000 pixels and the screen width is 200
         * pixels, the maximum scroll offset should be 800 pixels.
         */
        val (minX: Int, minY: Int) = 0 to 0
        val (maxX: Int, maxY: Int) =
            (mDisplayRect.width() - viewWidth).coerceAtLeast(0f).roundToInt() to
                    (mDisplayRect.height() - viewHeight).coerceAtLeast(0f).roundToInt()
        if (maxX != 0 || maxY != 0) {
            mScroller.fling(startX, startY, velocityX, velocityY, minX, maxX, minY, maxY)
        }
    }
    
    override fun run() {
        if (mScroller.isFinished) return
        if (mScroller.computeScrollOffset()) {
        	//fling未完成时,获取新的位置并移动图片,然后进行继续下一步的动画
            val newX = mScroller.currX
            val newY = mScroller.currY
            mDecoMatrix.postTranslate(
                (mCurrentX - newX).toFloat(),
                (mCurrentY - newY).toFloat()
            )
            updateImageMatrix()
            mCurrentX = newX
            mCurrentY = newY
            ViewCompat.postOnAnimation(mImageView, this)
        }
    }
    
    fun finish() {
        mScroller.forceFinished(true)
    }
}
复制代码

图片双击缩放

双击缩放需要监听双击事件以及提供图片缩放动画。双击事件的监听通过OnDoubleTapListeneronDoubleTap回调,然后触发一个缩放动画

//放大动画
private fun startScaleUp(px: Float, py: Float, start: Float): Animation {
    return object : Animation() {
        override fun applyTransformation(interpolatedTime: Float, t: Transformation) {
            val currentScale = mDecoMatrix.getScale()
            val targetScale = (MAX_SCALE - start) * interpolatedTime + start
            mDecoMatrix.postScale(
                targetScale / currentScale, targetScale / currentScale, px, py
            )
            updateImageMatrix()
        }
    }.apply {
        duration = SCALE_DURATION.toLong()
        mImageView.clearAnimation()
        mImageView.startAnimation(this)
    }
}
//缩小动画
private fun startScaleDown(px: Float, py: Float, start: Float): Animation {
    return object : Animation() {
        override fun applyTransformation(interpolatedTime: Float, t: Transformation) {
            val currentScale = mDecoMatrix.getScale()
            val targetScale = start - (start - 1f) * interpolatedTime
            mDecoMatrix.postScale(
                targetScale / currentScale, targetScale / currentScale,
                px, py
            )
            updateImageMatrix()
        }
    }.apply {
        duration = SCALE_DURATION.toLong()
        mImageView.clearAnimation()
        mImageView.startAnimation(this)
    }
}
复制代码

OnDoubleTapListeneronDoubleTap的实现,有参考SwipeRefreshLayout刷新View的动画处理方式。

override fun onDoubleTap(e: MotionEvent?): Boolean {
    e?.apply {
        val currentScale = mDecoMatrix.getScale()
        if (currentScale > 1f) {
            if (!mScaleDownAnimation.isAnimationRunning())
                mScaleDownAnimation = startScaleDown(e.x, e.y, currentScale)
        } else {
            if (!mScaleUpAnimation.isAnimationRunning())
                mScaleUpAnimation = startScaleUp(e.x, e.y, currentScale)
        }
    }
    return true
}
复制代码

结语

Google提供了一套完整的手势监听回调而不需要自己再去手动处理点击事件等行为,综上能够实现基本的图片缩放平移手势操作。

文章分类
Android
文章标签