在 Android 中实现支持多手势交互的自定义 View(Kotlin 完整指南)

5 阅读6分钟

本文将手把手教你创建一个支持拖动、缩放、旋转等多种手势交互的自定义 View,并提供完整的代码实现和优化建议。

一、基础实现

1.1 创建自定义 View 骨架

import android.content.Context
import android.graphics.*
import android.util.AttributeSet
import android.view.*

class InteractiveView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {

    // 绘制相关
    private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
        color = Color.BLUE
        style = Paint.Style.FILL
    }
    private var circleRadius = 100f
    private val originalRect = RectF()

    // 变换参数
    private var offsetX = 0f
    private var offsetY = 0f
    private var scaleFactor = 1f
    private var rotationAngle = 0f

    // 手势检测器
    private val gestureDetector: GestureDetector
    private val scaleDetector: ScaleGestureDetector
    private val rotationDetector: RotationGestureDetector

    init {
        // 初始化手势检测器
        gestureDetector = GestureDetector(context, GestureListener())
        scaleDetector = ScaleGestureDetector(context, ScaleListener())
        rotationDetector = RotationGestureDetector(context, RotationListener())
    }

    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        super.onSizeChanged(w, h, oldw, oldh)
        originalRect.set(0f, 0f, w.toFloat(), h.toFloat())
    }

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        canvas.save()
        
        // 应用变换
        canvas.translate(offsetX, offsetY)
        canvas.scale(scaleFactor, scaleFactor, pivotX, pivotY)
        canvas.rotate(rotationAngle, pivotX, pivotY)
        
        // 绘制内容
        canvas.drawCircle(
            originalRect.centerX(),
            originalRect.centerY(),
            circleRadius,
            paint
        )
        
        canvas.restore()
    }

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

    // 其他实现将在下文展开...
}

1.2 实现基本手势

拖动处理:

private inner class GestureListener : GestureDetector.SimpleOnGestureListener() {
    override fun onScroll(
        e1: MotionEvent,
        e2: MotionEvent,
        distanceX: Float,
        distanceY: Float
    ): Boolean {
        offsetX -= distanceX
        offsetY -= distanceY
        applyBoundaryConstraints()
        invalidate()
        return true
    }

    override fun onDoubleTap(e: MotionEvent): Boolean {
        // 双击重置变换
        resetTransformations()
        invalidate()
        return true
    }
}

private fun resetTransformations() {
    offsetX = 0f
    offsetY = 0f
    scaleFactor = 1f
    rotationAngle = 0f
}

缩放处理:

private var pivotX = 0f
private var pivotY = 0f

private inner class ScaleListener : ScaleGestureDetector.SimpleOnScaleGestureListener() {
    override fun onScaleBegin(detector: ScaleGestureDetector): Boolean {
        pivotX = detector.focusX
        pivotY = detector.focusY
        return true
    }

    override fun onScale(detector: ScaleGestureDetector): Boolean {
        val scale = detector.scaleFactor
        scaleFactor *= scale
        scaleFactor = scaleFactor.coerceIn(0.1f, 5f)
        
        // 调整中心点偏移
        offsetX += (pivotX - offsetX) * (1 - scale)
        offsetY += (pivotY - offsetY) * (1 - scale)
        
        invalidate()
        return true
    }
}

二、高级手势实现

2.1 旋转手势检测器

class RotationGestureDetector(
    context: Context,
    private val listener: OnRotationGestureListener
) {
    private var prevAngle = 0f
    
    fun onTouchEvent(event: MotionEvent): Boolean {
        if (event.pointerCount != 2) return false
        
        when (event.actionMasked) {
            MotionEvent.ACTION_POINTER_DOWN -> {
                prevAngle = getAngle(event)
            }
            MotionEvent.ACTION_MOVE -> {
                val newAngle = getAngle(event)
                listener.onRotate(newAngle - prevAngle)
                prevAngle = newAngle
            }
        }
        return true
    }
    
    private fun getAngle(event: MotionEvent): Float {
        val dx = event.getX(0) - event.getX(1)
        val dy = event.getY(0) - event.getY(1)
        return Math.toDegrees(atan2(dy.toDouble(), dx.toDouble())).toFloat()
    }

    interface OnRotationGestureListener {
        fun onRotate(angleDelta: Float)
    }
}

// 在 InteractiveView 中添加:
private inner class RotationListener : RotationGestureDetector.OnRotationGestureListener {
    override fun onRotate(angleDelta: Float) {
        rotationAngle += angleDelta
        rotationAngle %= 360
        invalidate()
    }
}

2.2 边界约束

private fun applyBoundaryConstraints() {
    val scaledWidth = originalRect.width() * scaleFactor
    val scaledHeight = originalRect.height() * scaleFactor
    
    val maxOffsetX = (scaledWidth - originalRect.width()) / 2
    val maxOffsetY = (scaledHeight - originalRect.height()) / 2
    
    offsetX = offsetX.coerceIn(-maxOffsetX, maxOffsetX)
    offsetY = offsetY.coerceIn(-maxOffsetY, maxOffsetY)
}

三、性能优化实现

惯性滑动

private val scroller = Scroller(context)

override fun computeScroll() {
    if (scroller.computeScrollOffset()) {
        offsetX = scroller.currX.toFloat()
        offsetY = scroller.currY.toFloat()
        applyBoundaryConstraints()
        invalidate()
    }
}

private inner class GestureListener : GestureDetector.SimpleOnGestureListener() {
    override fun onFling(
        e1: MotionEvent,
        e2: MotionEvent,
        velocityX: Float,
        velocityY: Float
    ): Boolean {
        scroller.fling(
            offsetX.toInt(),
            offsetY.toInt(),
            velocityX.toInt(),
            velocityY.toInt(),
            Int.MIN_VALUE,
            Int.MAX_VALUE,
            Int.MIN_VALUE,
            Int.MAX_VALUE
        )
        invalidate()
        return true
    }
}

四、完整布局示例

<com.example.app.InteractiveView
    android:id="@+id/interactiveView"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="#F0F0F0"/>

五、最佳实践建议

  1. 绘制优化:

    override fun onDraw(canvas: Canvas) {
        // 避免在绘制过程中创建新对象
        canvas.drawCircle(
            originalRect.centerX(),
            originalRect.centerY(),
            circleRadius,
            paint // 重用预定义的 Paint 对象
        )
    }
    
  2. 手势优先级处理:

    override fun onTouchEvent(event: MotionEvent): Boolean {
        when {
            scaleDetector.isInProgress -> scaleDetector.onTouchEvent(event)
            rotationDetector.isInProgress -> rotationDetector.onTouchEvent(event)
            else -> gestureDetector.onTouchEvent(event)
        }
        return true
    }
    
  3. 多指触控处理:

    private var activePointerId = MotionEvent.INVALID_POINTER_ID
    
    override fun onTouchEvent(event: MotionEvent): Boolean {
        when (event.actionMasked) {
            MotionEvent.ACTION_POINTER_DOWN -> {
                activePointerId = event.getPointerId(event.actionIndex)
            }
            MotionEvent.ACTION_POINTER_UP -> {
                val remainingPointer = if (event.actionIndex == 0) 1 else 0
                activePointerId = event.getPointerId(remainingPointer)
            }
        }
        // 其他手势处理...
    }
    

六、完整功能集成

最终的自定义 View 将支持:

  • 单指拖动
  • 双指缩放(带锚点)
  • 双指旋转
  • 惯性滑动
  • 双击重置
  • 边界约束
  • 多指触控支持

可以通过组合这些手势实现复杂的交互效果,如图片查看器、可操作图表等。


以下是为您整合所有功能的完整实现代码:

import android.content.Context
import android.graphics.*
import android.util.AttributeSet
import android.view.*
import android.widget.OverScroller
import kotlin.math.*

class AdvancedInteractiveView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {

    // 绘制参数
    private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
        color = Color.BLUE
        style = Paint.Style.FILL
    }
    private var circleRadius = 100f
    private val originalRect = RectF()

    // 变换参数
    private var offsetX = 0f
    private var offsetY = 0f
    private var scaleFactor = 1f
    private var rotationAngle = 0f
    private var pivotX = 0f
    private var pivotY = 0f

    // 手势检测器
    private val gestureDetector: GestureDetector
    private val scaleDetector: ScaleGestureDetector
    private val rotationDetector: RotationGestureDetector
    private val scroller = OverScroller(context)

    // 边界约束参数
    private var minScale = 0.5f
    private var maxScale = 5f
    private var isScaling = false
    private var activePointerId = MotionEvent.INVALID_POINTER_ID

    init {
        gestureDetector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
            override fun onScroll(
                e1: MotionEvent,
                e2: MotionEvent,
                distanceX: Float,
                distanceY: Float
            ): Boolean {
                if (!isScaling) {
                    offsetX -= distanceX
                    offsetY -= distanceY
                    applyBoundaryConstraints()
                    invalidate()
                }
                return true
            }

            override fun onDoubleTap(e: MotionEvent): Boolean {
                resetTransformations()
                invalidate()
                return true
            }

            override fun onFling(
                e1: MotionEvent,
                e2: MotionEvent,
                velocityX: Float,
                velocityY: Float
            ): Boolean {
                scroller.fling(
                    offsetX.toInt(),
                    offsetY.toInt(),
                    velocityX.toInt(),
                    velocityY.toInt(),
                    Int.MIN_VALUE,
                    Int.MAX_VALUE,
                    Int.MIN_VALUE,
                    Int.MAX_VALUE,
                    100,
                    100
                )
                invalidate()
                return true
            }
        })

        scaleDetector = ScaleGestureDetector(context, object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
            override fun onScaleBegin(detector: ScaleGestureDetector): Boolean {
                isScaling = true
                pivotX = detector.focusX
                pivotY = detector.focusY
                return true
            }

            override fun onScale(detector: ScaleGestureDetector): Boolean {
                val scale = detector.scaleFactor
                val newScale = scaleFactor * scale
                
                if (newScale in minScale..maxScale) {
                    // 调整偏移量保持锚点位置
                    offsetX += (pivotX - offsetX) * (1 - scale)
                    offsetY += (pivotY - offsetY) * (1 - scale)
                    scaleFactor = newScale
                }
                
                applyBoundaryConstraints()
                invalidate()
                return true
            }

            override fun onScaleEnd(detector: ScaleGestureDetector) {
                isScaling = false
            }
        })

        rotationDetector = RotationGestureDetector(object : RotationGestureDetector.OnRotationGestureListener {
            override fun onRotate(angleDelta: Float) {
                rotationAngle += angleDelta
                rotationAngle %= 360
                invalidate()
            }
        })
    }

    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        super.onSizeChanged(w, h, oldw, oldh)
        originalRect.set(0f, 0f, w.toFloat(), h.toFloat())
        resetTransformations()
    }

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        canvas.save()
        
        // 应用变换矩阵
        canvas.translate(offsetX, offsetY)
        canvas.scale(scaleFactor, scaleFactor, pivotX, pivotY)
        canvas.rotate(rotationAngle, pivotX, pivotY)
        
        // 绘制圆形
        canvas.drawCircle(
            originalRect.centerX(),
            originalRect.centerY(),
            circleRadius,
            paint
        )
        
        canvas.restore()
    }

    override fun computeScroll() {
        if (scroller.computeScrollOffset()) {
            offsetX = scroller.currX.toFloat()
            offsetY = scroller.currY.toFloat()
            applyBoundaryConstraints()
            invalidate()
        }
    }

    override fun onTouchEvent(event: MotionEvent): Boolean {
        scaleDetector.onTouchEvent(event)
        gestureDetector.onTouchEvent(event)
        rotationDetector.onTouchEvent(event)
        handleMultiTouch(event)
        return true
    }

    private fun handleMultiTouch(event: MotionEvent) {
        when (event.actionMasked) {
            MotionEvent.ACTION_POINTER_DOWN -> {
                activePointerId = event.getPointerId(event.actionIndex)
            }
            MotionEvent.ACTION_POINTER_UP -> {
                val remainingPointer = if (event.actionIndex == 0) 1 else 0
                activePointerId = event.getPointerId(remainingPointer)
            }
        }
    }

    private fun applyBoundaryConstraints() {
        val viewWidth = originalRect.width()
        val viewHeight = originalRect.height()
        
        val scaledWidth = viewWidth * scaleFactor
        val scaledHeight = viewHeight * scaleFactor
        
        val maxOffsetX = (scaledWidth - viewWidth) / 2
        val maxOffsetY = (scaledHeight - viewHeight) / 2
        
        offsetX = offsetX.coerceIn(-maxOffsetX, maxOffsetX)
        offsetY = offsetY.coerceIn(-maxOffsetY, maxOffsetY)
    }

    private fun resetTransformations() {
        offsetX = 0f
        offsetY = 0f
        scaleFactor = 1f
        rotationAngle = 0f
        pivotX = originalRect.centerX()
        pivotY = originalRect.centerY()
        invalidate()
    }

    // 自定义旋转手势检测器
    private class RotationGestureDetector(
        private val listener: OnRotationGestureListener
    ) {
        private var prevAngle = 0f
        
        fun onTouchEvent(event: MotionEvent): Boolean {
            if (event.pointerCount != 2) return false
            
            when (event.actionMasked) {
                MotionEvent.ACTION_POINTER_DOWN -> prevAngle = getAngle(event)
                MotionEvent.ACTION_MOVE -> {
                    val newAngle = getAngle(event)
                    listener.onRotate(newAngle - prevAngle)
                    prevAngle = newAngle
                }
            }
            return true
        }
        
        private fun getAngle(event: MotionEvent): Float {
            val dx = event.getX(0) - event.getX(1)
            val dy = event.getY(0) - event.getY(1)
            return Math.toDegrees(atan2(dy.toDouble(), dx.toDouble())).toFloat()
        }

        interface OnRotationGestureListener {
            fun onRotate(angleDelta: Float)
        }
    }
}

功能实现说明

  1. 单指拖动

    • 通过GestureDetector检测滚动事件
    • onScroll中更新offsetX/Y
    • 添加边界约束防止移出可视区域
  2. 双指缩放(带锚点)

    • 使用ScaleGestureDetector检测缩放手势
    • 记录缩放锚点(pivotX, pivotY)
    • 动态调整偏移量保持锚点位置稳定
    • 限制缩放范围(0.5-5倍)
  3. 双指旋转

    • 自定义RotationGestureDetector计算旋转角度
    • 通过两点坐标计算旋转角度差值
    • 更新rotationAngle并限制在0-360度之间
  4. 惯性滑动

    • 使用OverScroller实现流畅的惯性滑动
    • onFling中初始化滑动参数
    • computeScroll中持续更新位置
  5. 双击重置

    • onDoubleTap中重置所有变换参数
    • 重置位置、缩放、旋转到初始状态
  6. 边界约束

    • applyBoundaryConstraints方法计算最大偏移量
    • 根据当前缩放比例动态调整边界限制
    • 在每次位置变化后调用约束方法
  7. 多指触控支持

    • 处理ACTION_POINTER_DOWN/UP事件
    • 跟踪活动指针ID
    • 正确处理多指手势的切换

使用方式

  1. 在XML布局中添加:
<com.your.package.AdvancedInteractiveView
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="#F0F0F0"/>
  1. 自定义属性建议(可选):
<resources>
    <declare-styleable name="AdvancedInteractiveView">
        <attr name="minScale" format="float" />
        <attr name="maxScale" format="float" />
        <attr name="shapeColor" format="color" />
    </declare-styleable>
</resources>

性能优化建议

  1. 硬件加速
<application android:hardwareAccelerated="true">
  1. 绘制优化
  • 避免在onDraw中创建新对象
  • 使用canvas.saveLayer()替代多次绘制
  • 对于复杂图形使用Bitmap缓存
  1. 手势优化
  • 设置合适的手势检测阈值
  • 使用ViewConfiguration获取系统标准值:
private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop
  1. 内存管理
  • 在onDetachedFromWindow中释放资源
  • 使用弱引用持有上下文

可根据需要调整以下参数:

  • circleRadius:初始圆形半径
  • minScale/maxScale:缩放范围限制
  • 颜色和样式通过Paint对象自定义
  • 边界约束计算逻辑调整

实际使用时可扩展以下功能:

  • 添加更多图形元素
  • 实现手势冲突解决策略
  • 添加触摸反馈动画
  • 支持更多手势(如长按、快速滑动等)