一个支持拖动、双指缩放、旋转等功能的图片查看器是常见的需求,本文将通过 GestureDetectorScaleGestureDetector 以及自定义的 RotationGestureDetector 配合 Matrix 实现一个简易的图片手势处理组件 —— GestureImageView

一、功能概览

GestureDetector.OnGestureListener、OnDoubleTapListener 是系统提供的手势识别的两个接口,其内部依赖 MotionEvent,通过不同的手势判断逻辑触发回调。

GestureDetector.SimpleOnGestureListener 实现了上述两个接口,可以重写自己想要的方法,比如现在想触发双击时的手势:

private val mGestureDetectorListener = object : GestureDetector.SimpleOnGestureListener() {
  //快速点击两次,触发该方法
  override fun onDoubleTap(e: MotionEvent?): Boolean {
            zoomImage() //在这里实现缩放图片
            return true
        }
}

private var gestureDetector: GestureDetector = GestureDetector(context, mGestureDetectorListener)

override fun onTouchEvent(event: MotionEvent?): Boolean {
     gestureDetector.onTouchEvent(event)
     return true
}

上述代码就完成了一个双击手势的监听。除此之外,GestureDetector.SimpleOnGestureListener 还提供了多个其他手势的重写方法:

方法名 说明
onDown(MotionEvent e) 手指按下时触发
onShowPress(MotionEvent e) 手指按下后短时间未移动或未抬起
onSingleTapUp(MotionEvent e) 抬起时触发的单击事件
onScroll(e1, e2, distanceX, distanceY) 手指滑动时触发
onLongPress(MotionEvent e) 长按触发,如:按住屏幕超过一定时间(默认 500ms)且未滑动,会触发此方法
onFling(e1, e2, velocityX, velocityY) 快速滑动后抬起手指(惯性滑动)时触发
onDoubleTap(MotionEvent e) 双击触发
onDoubleTapEvent(MotionEvent e) 双击过程中的事件触发
onSingleTapConfirmed(MotionEvent e) 确认是单击而非双击的一次点击

二、效果图

请添加图片描述

  • 双指缩放:通过 ScaleGestureDetector 控制 Matrix.postScale() 实现
  • 拖动移动:通过 GestureDetector.onScroll() 控制 Matrix.postTranslate() 实现
  • 双击缩放:通过 GestureDetector.onDoubleTap() 实现快速放大或还原
  • 双指旋转:通过自定义 RotationGestureDetector 控制 Matrix.postRotate() 实现

三、核心代码结构

1. GestureImageView 继承自 AppCompatImageView

class GestureImageView @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null
) : AppCompatImageView(context, attrs)

初始化中设置 scaleType = ScaleType.MATRIX,并注册多个手势监听器:

scaleGestureDetector = ScaleGestureDetector(context, mScaleGestureListener)
gestureDetector = GestureDetector(context, mGestureDetectorListener)
rotationGestureDetector = RotationGestureDetector(mRotationGestureListener)

2. 缩放手势监听 ScaleGestureDetector

  • onScale() 中通过 detector.scaleFactor 获取缩放系数
  • 调用 matrix.postScale() 实现围绕焦点缩放
  • 使用 coerceIn() 限定缩放区间避免图片过小或过大
override fun onScale(detector: ScaleGestureDetector?): Boolean {
    val preScale = currentScale
    currentScale = (currentScale * detector.scaleFactor).coerceIn(0.5f, 3.0f)
    val realFactor = currentScale / preScale
    matrix.postScale(realFactor, realFactor, detector.focusX, detector.focusY)
    imageMatrix = matrix
    return true
}

3. 拖动与双击缩放 GestureDetector

  • 拖动通过 onScroll() 监听并调用 matrix.postTranslate() 实现
  • 双击通过 onDoubleTap() 切换缩放倍数并居中
override fun onScroll(e1: MotionEvent?, e2: MotionEvent?, distanceX: Float, distanceY: Float): Boolean {
    matrix.postTranslate(-distanceX, -distanceY)
    imageMatrix = matrix
    return true
}

override fun onDoubleTap(e: MotionEvent?): Boolean {
    zoomImage()
    return true
}

private fun zoomImage() {
    if (currentScale == mDefaultScale) {
        scaleFactor = if (isZoomed) 0.5f else 2f
        matrix.postScale(scaleFactor, scaleFactor, width / 2f, height / 2f)
        isZoomed = !isZoomed
        imageMatrix = matrix
        invalidate()
    } else {
        setImgToCenter(this)
        currentScale = mDefaultScale
        isZoomed = false
    }
}

4. 自定义 RotationGestureDetector

通过监听两指的角度差,计算旋转角度并传递给 GestureImageView 控制旋转:

class RotationGestureDetector(private val listener: OnRotationGestureListener) {
    private var prevAngle = 0f

    fun onTouchEvent(event: MotionEvent?) {
        if (event?.pointerCount == 2) {
            val angle = calculateAngle(event)
            val delta = angle - prevAngle
            listener.onRotation(this, delta)
            prevAngle = angle
        }
    }

    private fun calculateAngle(event: MotionEvent): Float {
        val dx = event.getX(1) - event.getX(0)
        val dy = event.getY(1) - event.getY(0)
        return Math.toDegrees(atan2(dy.toDouble(), dx.toDouble())).toFloat()
    }
}

5、居中缩放图片 setImgToCenter()

初始设置图片缩放并居中展示:

val widthScale = img.width.toFloat() / it.intrinsicWidth
val heightScale = img.height.toFloat() / it.intrinsicHeight
val scale = min(widthScale, heightScale)
val dx = (img.width - it.intrinsicWidth * scale) / 2
val dy = (img.height - it.intrinsicHeight * scale) / 2
matrix.postScale(scale, scale)
matrix.postTranslate(dx, dy)

配合 Matrix 灵活组合平移、缩放、旋转变换,效果顺滑自然。可作为自定义图片查看器的基础组件。

GestureImageView完整代码如下:

class GestureImageView @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null
) : AppCompatImageView(context, attrs) {
    private var isZoomed = false
    private var scaleFactor = 2f // 放大倍数
    private var mDefaultScale = 1f

    /**
     * 双指缩放时调用,方法调用顺序:
     * 1. onScaleBegin(detector)   //1、缩放开始时调用
     * 2. onScale(detector)        //2、每一帧的缩放变动会调用多次
     * 3. ...
     * 4. ...
     * 5. onScale(...)
     * 6. onScaleEnd(detector)     //3、缩放结束(手指离开)时调用
     */
    private val mScaleGestureListener = object : ScaleGestureDetector.OnScaleGestureListener {

        /**
         * 当两个手指在屏幕上移动时持续调用。在这个方法中可以通过detector.getScaleFactor()获取缩放因子,更新视图的缩放逻辑
         */
        override fun onScale(detector: ScaleGestureDetector?): Boolean {
            log("onScale():$currentScale")
            if (detector == null) return false
            val preScale = currentScale
            //detector.scaleFactor 表示当前帧的缩放因子(如1.05表示放大5%,0.95表示缩小5%)
            currentScale = (currentScale * detector.scaleFactor).coerceIn(0.5f, 3.0f) //限制缩放比例
            val realFactor = currentScale / preScale //计算限制后的实际缩放因子:新的/旧的,进行缩放矫正
            //focusX/focusY表示两个手指的中心点(焦点)坐标
            val focusX = detector.focusX
            val focusY = detector.focusY
            isZoomed = (currentScale > mDefaultScale)
            matrix.postScale(realFactor, realFactor, focusX, focusY)
            imageMatrix = matrix
            return true
        }

        /**
         * 缩放手势开始时调用
         */
        override fun onScaleBegin(detector: ScaleGestureDetector?): Boolean {
            log("onScaleBegin():$currentScale")
            return true
        }

        /**
         * 缩放手势结束时调用
         */
        override fun onScaleEnd(detector: ScaleGestureDetector?) {
            log("onScaleEnd():$currentScale")
        }
    }

    private val mGestureDetectorListener = object : GestureDetector.SimpleOnGestureListener() {

        //-----------------GestureDetector.OnGestureListener start-----------------
        override fun onDown(e: MotionEvent?): Boolean {
            log("onDown():${e?.actionMasked}")
            return true
        }

        /**
         * 手指按下后,短暂静止时触发(未抬起/滑动),用于提供视觉提示
         */
        override fun onShowPress(e: MotionEvent?) {
            log("onShowPress():${e?.actionMasked}")
        }

        /**
         * 户点击(按下再抬起)时调用。 true:表示消费了这个点击事件
         */
        override fun onSingleTapUp(e: MotionEvent?): Boolean {
            log("onSingleTapUp():${e?.actionMasked}")
            return false
        }

        /**
         * 拖动手势中持续触发,表示手指在滑动
         *
         * @param e1
         * @param e2
         * @param distanceX
         * @param distanceY
         * distanceX 和 distanceY 表示当前事件和前一个事件之间的移动距离(不是总距离)。手指滑动方向:
         * distanceX:👉 向右滑动为负数,x减小;👈 向左滑动正数,x增加  (preX - curX)
         * distanceY:👇 向下滑动负数,y减小;👆 向上滑动正数,y增加  (preY - curY)
         */
        override fun onScroll(
            e1: MotionEvent?,
            e2: MotionEvent?,
            distanceX: Float,
            distanceY: Float
        ): Boolean {
            log("onScroll() -> e1:${e1?.actionMasked}, e2:${e2?.actionMasked},distanceX:$distanceX,distanceY:$distanceY")
            matrix.postTranslate(-distanceX, -distanceY)
            imageMatrix = matrix
            return true
        }

        /**
         * 用户按住屏幕超过一定时间(默认 500ms)且未滑动,会触发此方法
         */
        override fun onLongPress(e: MotionEvent?) {
            log("onLongPress():${e?.actionMasked}")
        }

        /**
         * 快速滑动后抬起手指(惯性滑动)时触发
         *
         * @param e1
         * @param e2
         * @param velocityX
         * @param velocityY
         * @return true表示消费了这个fling手势
         */
        override fun onFling(
            e1: MotionEvent?,
            e2: MotionEvent?,
            velocityX: Float,
            velocityY: Float
        ): Boolean {
            log("onFling() -> e1:${e1?.actionMasked}, e2:${e2?.actionMasked},velocityX:$velocityX,velocityY:$velocityY")
            return false
        }
        //-----------------GestureDetector.OnGestureListener end-----------------
        //-----------------GestureDetector.OnDoubleTapListener start-----------------

        /**
         * 快速点击两次,触发该方法
         */
        override fun onDoubleTap(e: MotionEvent?): Boolean {
            log("onDoubleTap():${e?.actionMasked}")
            zoomImage()
            return true
        }

        /**
         * 在双击过程中,down, move, up 都会回调到这里
         */
        override fun onDoubleTapEvent(e: MotionEvent?): Boolean {
            log("onDoubleTapEvent():${e?.actionMasked}")
            return super.onDoubleTapEvent(e)
        }

        /**
         * 确认是单击(而非双击的一部分)时触发
         */
        override fun onSingleTapConfirmed(e: MotionEvent?): Boolean {
            log("onSingleTapConfirmed():${e?.actionMasked}")
            return super.onSingleTapConfirmed(e)
        }
        //-----------------GestureDetector.OnDoubleTapListener end-----------------
        //-----------------GestureDetector.OnContextClickListener start-----------------

        /**
         * 支持鼠标右键或触控板的 context click(上下文点击),适用于 Android TV 或外接鼠标设备
         */
        override fun onContextClick(e: MotionEvent?): Boolean {
            log("onContextClick():${e?.actionMasked}")
            return super.onContextClick(e)
        }
        //-----------------GestureDetector.OnContextClickListener end-----------------
    }

    private val mRotationGestureListener = object : RotationGestureDetector.OnRotationGestureListener {
        override fun onRotation(rotationDetector: RotationGestureDetector, angle: Float) {
            currentRotation += angle
            val px = width / 2f
            val py = height / 2f
            matrix.postRotate(angle, px, py)
            imageMatrix = matrix
        }
    }

    private val matrix = Matrix()
    private var currentScale = 1f //当前缩放了多少倍
    private var currentRotation = 0f

    private var scaleGestureDetector: ScaleGestureDetector
    private var gestureDetector: GestureDetector
    private var rotationGestureDetector: RotationGestureDetector

    init {
        scaleType = ScaleType.MATRIX
        scaleGestureDetector = ScaleGestureDetector(context, mScaleGestureListener)
        gestureDetector = GestureDetector(context, mGestureDetectorListener)
        rotationGestureDetector = RotationGestureDetector(mRotationGestureListener)
        setImgToCenter(this)
    }

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

    private fun zoomImage() {
        log("currentScale:$currentScale,isZoomed:$isZoomed")
        if (currentScale == mDefaultScale) {
            scaleFactor = if (isZoomed) 0.5f else 2f
            matrix.postScale(scaleFactor, scaleFactor, width / 2f, height / 2f)
            isZoomed = !isZoomed
            imageMatrix = matrix
            invalidate()
        } else {
            setImgToCenter(this)
            currentScale = mDefaultScale
            isZoomed = false
        }
    }

    private fun getCurScale(): Float {
        val mArr = FloatArray(9)
        imageMatrix.getValues(mArr)
        return min(mArr[Matrix.MSCALE_X], mArr[Matrix.MSCALE_Y])
    }

    private fun setImgToCenter(img: ImageView) {
        img.post {
            val drawable = img.drawable //图片
            drawable?.let {
                matrix.reset()
                //计算宽高比例
                val widthScale = img.width.toFloat() / it.intrinsicWidth
                val heightScale = img.height.toFloat() / it.intrinsicHeight
                //选择较小的缩放比
                val scale = min(widthScale, heightScale)
                //缩放后的图片尺寸
                val scaledWidth = it.intrinsicWidth * scale
                val scaleHeight = it.intrinsicHeight * scale
                //计算平移量
                val dx = (img.width - scaledWidth) / 2
                val dy = (img.height - scaleHeight) / 2
                matrix.postScale(scale, scale)
                matrix.postTranslate(dx, dy)
                img.imageMatrix = matrix
            }
        }
    }
}

示例完整代码参见:https://github.com/crazyqiang/AndroidStudy/tree/master/app/src/main/kotlin/org/ninetripods/mq/study/widget/matrix

注意:本文只是为了学习下GestureDetector、Matrix的用法,如果想实现一个完整图片浏览功能,可以参见一些三方的成熟库,如:https://github.com/Baseflow/PhotoView

Logo

火山引擎开发者社区是火山引擎打造的AI技术生态平台,聚焦Agent与大模型开发,提供豆包系列模型(图像/视频/视觉)、智能分析与会话工具,并配套评测集、动手实验室及行业案例库。社区通过技术沙龙、挑战赛等活动促进开发者成长,新用户可领50万Tokens权益,助力构建智能应用。

更多推荐