Android自定义控件 支持移动、缩放、旋转功能的ImageView

封面

转载请注明出处:https://www.jianshu.com/p/c954e2aea2f3

本文出自 容华谢后的博客

0.写在前面

今天写一篇关于自定义控件的文章,基于ImageView控件,给它加上移动、多点缩放、两指旋转的功能,先看下效果:

效果图

布局中可以添加多个MatrixImage,位置可以自由移动,涉及到一些简单的三角函数知识,说下实现的思路:

  • 基于ImageView,因为要实现缩放、移动、旋转功能,将ImageView的scaleType设置为MATRIX模式

  • 获取图片的显示区域,得到上、下、左、右位置信息

  • 根据图片的显示区域,绘制四个边框,边框随着图片的区域变化而变化

  • 绘制每个角的控制点,根据控制点的位置,实现缩放功能

  • 重写onTouchEvent方法,实现图片的移动和旋转功能

一起来看下实现的代码逻辑,代码比较多,完整的项目代码在文末贴上。

1.准备

先初始化一些参数:

class MatrixImageView : AppCompatImageView {

    // 控件宽度
    private var mWidth = 0

    // 控件高度
    private var mHeight = 0

    // 第一次绘制
    private var mFirstDraw = true

    // 是否显示控制框
    private var mShowFrame = false

    // 当前Image矩阵
    private var mImgMatrix = Matrix()

    // 画笔
    private lateinit var mPaint: Paint

    // 触摸模式
    private var touchMode: MatrixImageUtils.TouchMode? = null

    // 第二根手指是否按下
    private var mIsPointerDown = false

    // 按下点x坐标
    private var mDownX = 0f

    // 按下点y坐标
    private var mDownY = 0f

    // 上一次的触摸点x坐标
    private var mLastX = 0f

    // 上一次的触摸点y坐标
    private var mLastY = 0f

    // 旋转角度
    private var mDegree: Float = 0.0f

    // 旋转图标
    private lateinit var mRotateIcon: Bitmap

    // 图片控制框颜色
    private var mFrameColor = Color.parseColor("#1677FF")

    // 连接线宽度
    private var mLineWidth = dp2px(context, 2f)

    // 缩放控制点半径
    var mScaleDotRadius = dp2px(context, 5f)

    // 旋转控制点半径
    var mRotateDotRadius = dp2px(context, 12f)

    // 按下监听
    private var mDownClickListener: ((view: View, pointF: PointF) -> Unit)? = null

    // 长按监听
    private var mLongClickListener: ((view: View, pointF: PointF) -> Unit)? = null

    // 移动监听
    private var mMoveListener: ((view: View, pointF: PointF) -> Unit)? = null

    // 长按监听计时任务
    private var mLongClickJob: Job? = null

    constructor(context: Context) : this(context, null)

    constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)

    constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(
        context,
        attrs,
        defStyleAttr
    ) {
        setAttribute(attrs)
        init()
    }

    ...
}

增加一些属性设置,可以在布局文件中对控件进行调整,初始化画笔和旋转控制点图标,控件的宽高在onSizeChanged方法中确定:

private fun setAttribute(attrs: AttributeSet?) {
    if (attrs == null) {
        return
    }
    val typedArray = context.obtainStyledAttributes(attrs, R.styleable.MatrixImageView)
    val indexCount = typedArray.indexCount
    for (i in 0 until indexCount) {
        when (val attr = typedArray.getIndex(i)) {
            R.styleable.MatrixImageView_fcLineWidth -> { // 连接线宽度
                mLineWidth = typedArray.getDimension(attr, mLineWidth)
            }
            R.styleable.MatrixImageView_fcScaleDotRadius -> { // 缩放控制点半径
                mScaleDotRadius = typedArray.getDimension(attr, mScaleDotRadius)
            }
            R.styleable.MatrixImageView_fcRotateDotRadius -> { // 旋转控制点半径
                mRotateDotRadius = typedArray.getDimension(attr, mRotateDotRadius)
            }
            R.styleable.MatrixImageView_fcFrameColor -> { // 图片控制框颜色
                mFrameColor = typedArray.getColor(attr, mFrameColor)
            }
        }
    }
    typedArray.recycle()
}

private fun init() {
    mPaint = Paint()
    mPaint.isAntiAlias = true
    mPaint.strokeWidth = mLineWidth
    mPaint.color = mFrameColor
    mPaint.style = Paint.Style.FILL

    // Matrix模式
    scaleType = ScaleType.MATRIX

    // 旋转图标
    val rotateIcon = decodeResource(resources, R.mipmap.ic_mi_rotate)
    val rotateIconWidth = (mRotateDotRadius * 1.6f).toInt()
    mRotateIcon = createScaledBitmap(rotateIcon, rotateIconWidth, rotateIconWidth, true)
}

override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
    super.onSizeChanged(w, h, oldw, oldh)
    this.mWidth = w
    this.mHeight = h
}

2.绘制

先获取图片的坐标信息,默认显示在控件中心,然后绘制边框和控制点:

override fun draw(canvas: Canvas?) {
    super.draw(canvas)
    if (canvas == null || drawable == null) {
        return
    }

    val imgRect = getImageRectF(this)
    // 左上角x坐标
    val left = imgRect.left
    // 左上角y坐标
    val top = imgRect.top
    // 右下角x坐标
    val right = imgRect.right
    // 右下角y坐标
    val bottom = imgRect.bottom

    // 图片移动到控件中心
    if (mFirstDraw) {
        mFirstDraw = false
        val centerX = (mWidth / 2).toFloat()
        val centerY = (mHeight / 2).toFloat()
        val imageWidth = right - left
        val imageHeight = bottom - top
        mImgMatrix.postTranslate(centerX - imageWidth / 2, centerY - imageHeight / 2)
        // 如果图片较大,缩放0.5倍
        if (imageWidth > width || imageHeight > height) {
            mImgMatrix.postScale(0.5f, 0.5f, centerX, centerY)
        }
        imageMatrix = mImgMatrix
    }

    // 不绘制控制框
    if (!mShowFrame) {
        return
    }

    // 上边框
    canvas.drawLine(left, top, right, top, mPaint)
    // 下边框
    canvas.drawLine(left, bottom, right, bottom, mPaint)
    // 左边框
    canvas.drawLine(left, top, left, bottom, mPaint)
    // 右边框
    canvas.drawLine(right, top, right, bottom, mPaint)

    // 左上角控制点,等比缩放
    canvas.drawCircle(left, top, mScaleDotRadius, mPaint)
    // 右上角控制点,等比缩放
    canvas.drawCircle(right, top, mScaleDotRadius, mPaint)
    // 左中间控制点,横向缩放
    canvas.drawCircle(left, top + (bottom - top) / 2, mScaleDotRadius, mPaint)
    // 右中间控制点,横向缩放
    canvas.drawCircle(right, top + (bottom - top) / 2, mScaleDotRadius, mPaint)
    // 左下角控制点,等比缩放
    canvas.drawCircle(left, bottom, mScaleDotRadius, mPaint)
    // 右下角控制点,等比缩放
    canvas.drawCircle(right, bottom, mScaleDotRadius, mPaint)
    // 下中间控制点,竖向缩放
    val middleX = (right - left) / 2 + left
    canvas.drawCircle(middleX, bottom, mScaleDotRadius, mPaint)
    // 上中间控制点,旋转
    val rotateLine = mRotateDotRadius / 3
    canvas.drawLine(middleX, top - rotateLine, middleX, top, mPaint)
    canvas.drawCircle(middleX, top - rotateLine - mRotateDotRadius, mRotateDotRadius, mPaint)
    // 上中间控制点,旋转图标
    canvas.drawBitmap(
        mRotateIcon,
        middleX - mRotateIcon.width / 2,
        top - rotateLine - mRotateDotRadius - mRotateIcon.width / 2,
        mPaint
    )
}

绘制完成是这样的效果:

边框和控制点

3.Touch事件处理

要处理移动、缩放、单个旋转控制点旋转,两指旋转这四种Touch事件,因为重写了onTouchEvent方法,还要再加上点击事件和长按事件的处理。

其中ACTION_POINTER_DOWN接收的是两指旋转中,第二根手指的坐标信息,单个旋转控制点旋转和两指旋转逻辑差不多,是以图片中心为第一根手指的位置,旋转控制点
为第二根手指的位置,关于旋转角度的计算,一起往下看。

override fun onTouchEvent(event: MotionEvent?): Boolean {
    if (event == null || drawable == null) {
        return super.onTouchEvent(event)
    }
    // x坐标
    val x = event.x
    // y坐标
    val y = event.y
    // 图片显示区域
    val imageRect = getImageRectF(this)
    // 图片中心点x坐标
    val centerX = (imageRect.right - imageRect.left) / 2 + imageRect.left
    // 图片中心点y坐标
    val centerY = (imageRect.bottom - imageRect.top) / 2 + imageRect.top

    when (event.action.and(ACTION_MASK)) {
        ACTION_DOWN -> {
            // 按下监听
            mDownClickListener?.invoke(this, PointF(x, y))
            // 判断是否在图片实际显示区域内
            touchMode = getTouchMode(this, x, y)
            if (touchMode == TOUCH_OUTSIDE) {
                mShowFrame = false
                invalidate()
                return super.onTouchEvent(event)
            }
            mDownX = x
            mDownY = y
            mLastX = x
            mLastY = y
            // 旋转控制点,点击后以图片中心为基准,计算当前旋转角度
            if (touchMode == TOUCH_ROTATE) {
                // 旋转角度
                mDegree = callRotation(centerX, centerY, x, y)
            }
            mShowFrame = true
            invalidate()

            // 长按监听计时
            mLongClickJob = coroutineDelay(Main, 500) {
                val offsetX = abs(x - mLastX)
                val offsetY = abs(y - mLastY)
                val offset = dp2px(context, 10f)
                if (offsetX <= offset && offsetY <= offset) {
                    mLongClickListener?.invoke(this, PointF(x, y))
                }
            }
            return true
        }
        ACTION_CANCEL -> {
            mLongClickJob?.cancel()
        }
        ACTION_POINTER_DOWN -> {
            mLongClickJob?.cancel()
            mDegree = callRotation(event)
            mIsPointerDown = true
            return true
        }
        ACTION_MOVE -> {
            // 旋转事件
            if (event.pointerCount == 2) {
                if (!mIsPointerDown) {
                    return true
                }
                val rotate = callRotation(event)
                val rotateNow = rotate - mDegree
                mDegree = rotate
                mImgMatrix.postRotate(rotateNow, centerX, centerY)
                imageMatrix = mImgMatrix
                return true
            }
            if (mIsPointerDown) {
                return true
            }
            // 移动、缩放事件
            touchMove(x, y, imageRect)
            mLastX = x
            mLastY = y
            invalidate()
            val offsetX = abs(x - mDownX)
            val offsetY = abs(y - mDownY)
            val offset = dp2px(context, 10f)
            if (offsetX > offset || offsetY > offset) {
                mMoveListener?.invoke(this, PointF(x, y))
            }
            return true
        }
        ACTION_UP -> {
            mLongClickJob?.cancel()
            touchMode = null
            mIsPointerDown = false
            mDegree = 0f
        }
    }
    return super.onTouchEvent(event)
}

touchMove方法主要处理图片的移动、旋转、缩放功能,在上述onTouchEvent方法中的ACTION_MOVE中被触发:

/**
 * 手指移动
 *
 * @param x         x坐标
 * @param y         y坐标
 * @param imageRect 图片显示区域
 */
private fun touchMove(x: Float, y: Float, imageRect: RectF) {
    // 左上角x坐标
    val left = imageRect.left
    // 左上角y坐标
    val top = imageRect.top
    // 右下角x坐标
    val right = imageRect.right
    // 右下角y坐标
    val bottom = imageRect.bottom
    // 总的缩放距离,斜角
    val totalTransOblique = getDistanceOf2Points(left, top, right, bottom)
    // 总的缩放距离,水平
    val totalTransHorizontal = getDistanceOf2Points(left, top, right, top)
    // 总的缩放距离,垂直
    val totalTransVertical = getDistanceOf2Points(left, top, left, bottom)
    // 当前缩放距离
    val scaleTrans = getDistanceOf2Points(mLastX, mLastY, x, y)
    // 缩放系数,x轴方向
    val scaleFactorX: Float
    // 缩放系数,y轴方向
    val scaleFactorY: Float
    // 缩放基准点x坐标
    val scaleBaseX: Float
    // 缩放基准点y坐标
    val scaleBaseY: Float

    when (touchMode) {
        TOUCH_IMAGE -> {
            mImgMatrix.postTranslate(x - mLastX, y - mLastY)
            imageMatrix = mImgMatrix
            return
        }
        TOUCH_ROTATE -> {
            // 图片中心点x坐标
            val centerX = (imageRect.right - imageRect.left) / 2 + imageRect.left
            // 图片中心点y坐标
            val centerY = (imageRect.bottom - imageRect.top) / 2 + imageRect.top
            // 旋转角度
            val rotate = callRotation(centerX, centerY, x, y)
            val rotateNow = rotate - mDegree
            mDegree = rotate
            mImgMatrix.postRotate(rotateNow, centerX, centerY)
            imageMatrix = mImgMatrix
            return
        }
        TOUCH_CONTROL_1 -> {
            // 缩小
            scaleFactorX = if (x - mLastX > 0) {
                (totalTransOblique - scaleTrans) / totalTransOblique
            } else {
                (totalTransOblique + scaleTrans) / totalTransOblique
            }
            scaleFactorY = scaleFactorX
            // 右下角
            scaleBaseX = imageRect.right
            scaleBaseY = imageRect.bottom
        }
        TOUCH_CONTROL_2 -> {
            // 缩小
            scaleFactorX = if (x - mLastX < 0) {
                (totalTransOblique - scaleTrans) / totalTransOblique
            } else {
                (totalTransOblique + scaleTrans) / totalTransOblique
            }
            scaleFactorY = scaleFactorX
            // 左下角
            scaleBaseX = imageRect.left
            scaleBaseY = imageRect.bottom
        }
        TOUCH_CONTROL_3 -> {
            // 缩小
            scaleFactorX = if (x - mLastX > 0) {
                (totalTransOblique - scaleTrans) / totalTransOblique
            } else {
                (totalTransOblique + scaleTrans) / totalTransOblique
            }
            scaleFactorY = scaleFactorX
            // 右上角
            scaleBaseX = imageRect.right
            scaleBaseY = imageRect.top
        }
        TOUCH_CONTROL_4 -> {
            // 缩小
            scaleFactorX = if (x - mLastX < 0) {
                (totalTransOblique - scaleTrans) / totalTransOblique
            } else {
                (totalTransOblique + scaleTrans) / totalTransOblique
            }
            scaleFactorY = scaleFactorX
            // 左上角
            scaleBaseX = imageRect.left
            scaleBaseY = imageRect.top
        }
        TOUCH_CONTROL_5 -> {
            // 缩小
            scaleFactorX = if (x - mLastX > 0) {
                (totalTransHorizontal - scaleTrans) / totalTransHorizontal
            } else {
                (totalTransHorizontal + scaleTrans) / totalTransHorizontal
            }
            scaleFactorY = 1f
            // 右上角
            scaleBaseX = imageRect.right
            scaleBaseY = imageRect.top
        }
        TOUCH_CONTROL_6 -> {
            // 缩小
            scaleFactorX = if (x - mLastX < 0) {
                (totalTransHorizontal - scaleTrans) / totalTransHorizontal
            } else {
                (totalTransHorizontal + scaleTrans) / totalTransHorizontal
            }
            scaleFactorY = 1f
            // 左上角
            scaleBaseX = imageRect.left
            scaleBaseY = imageRect.top
        }
        TOUCH_CONTROL_7 -> {
            // 缩小
            scaleFactorX = 1f
            scaleFactorY = if (y - mLastY < 0) {
                (totalTransVertical - scaleTrans) / totalTransVertical
            } else {
                (totalTransVertical + scaleTrans) / totalTransVertical
            }
            // 左上角
            scaleBaseX = imageRect.left
            scaleBaseY = imageRect.top
        }
        else -> {
            return
        }
    }

    // 最小缩放值限制
    val scaleMatrix = Matrix(mImgMatrix)
    scaleMatrix.postScale(scaleFactorX, scaleFactorY, scaleBaseX, scaleBaseY)
    val scaleRectF = getImageRectF(this, scaleMatrix)
    if (scaleRectF.right - scaleRectF.left < mScaleDotRadius * 6
        || scaleRectF.bottom - scaleRectF.top < mScaleDotRadius * 6
    ) {
        return
    }
    // 缩放
    mImgMatrix.postScale(scaleFactorX, scaleFactorY, scaleBaseX, scaleBaseY)
    imageMatrix = mImgMatrix
}

4.一些计算

4.1 获取图片在ImageView中的实际显示位置:

/**
 * 获取图片在ImageView中的实际显示位置
 *
 * @param view ImageView
 * @return RectF
 */
fun getImageRectF(view: ImageView): RectF {
    // 获得ImageView中Image的变换矩阵
    val matrix = view.imageMatrix
    return getImageRectF(view, matrix)
}

/**
 * 获取图片在ImageView中的实际显示位置
 *
 * @param view ImageView
 * @param matrix Matrix
 * @return RectF
 */
fun getImageRectF(view: ImageView, matrix: Matrix): RectF {
    // 获得ImageView中Image的显示边界
    val bounds = view.drawable.bounds
    val rectF = RectF()
    matrix.mapRect(
        rectF,
        RectF(
            bounds.left.toFloat(),
            bounds.top.toFloat(),
            bounds.right.toFloat(),
            bounds.bottom.toFloat()
        )
    )
    return rectF
}

4.2 计算旋转的角度

deltaX是图片中心点和旋转点的水平方向距离,deltaY是垂直方向距离,atan2是反正切,计算的是旋转控制点与中心点的连接线,与X轴的夹角弧度,然后通过toDegrees方法转换为夹角角度。

向右顺时针旋转,角度越来越大,角度递增图片向右旋转,向左则相反。

/**
 * 计算旋转的角度
 *
 * @param baseX 基准点x坐标
 * @param baseY 基准点y坐标
 * @param rotateX 旋转点x坐标
 * @param rotateY 旋转点y坐标
 * @return 旋转的角度
 */
fun callRotation(baseX: Float, baseY: Float, rotateX: Float, rotateY: Float): Float {
    val deltaX = (baseX - rotateX).toDouble()
    val deltaY = (baseY - rotateY).toDouble()
    val radius = atan2(deltaY, deltaX)
    return Math.toDegrees(radius).toFloat()
}

看图说话:

计算旋转的角度

了解下弧度与角度的计算公式:

  • 完整圆的弧度为2π,角度为360度,所以180度等于π弧度

  • 弧度 = 角度 / 180 * π

  • 角度 = 弧度 / π * 180

4.3 计算两点之间的距离

这个比较简单了,三角形已知两条直角边的值求斜边,勾股定理:a² + b² = c²

/**
 * 获取两个点之间的距离
 *
 * @param x1 第一个点x坐标
 * @param y1 第一个点y坐标
 * @param x2 第二个点x坐标
 * @param y2 第二个点y坐标
 * @return 两个点之间的距离
 */
internal fun getDistanceOf2Points(x1: Float, y1: Float, x2: Float, y2: Float): Float {
    return sqrt((x1 - x2).pow(2) + (y1 - y2).pow(2))
}

5.写在最后

最后附上多个控件的效果图:

多个控件效果图

GitHub地址:https://github.com/alidili/MatrixImage

到这里,自定义控件MatrixImage的基本步骤就介绍完了,如有问题可以给我留言评论或者在GitHub中提交Issues,谢谢!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,219评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,363评论 1 293
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,933评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,020评论 0 206
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,400评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,640评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,896评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,597评论 0 199
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,327评论 1 244
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,581评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,072评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,399评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,054评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,083评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,849评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,672评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,585评论 2 270

推荐阅读更多精彩内容