Android自定义View—直尺的旋转,缩放,平移以及拉伸

一、前提

上一篇文章中,我们实现了直尺的绘制Android自定义View---直尺 - 掘金 (juejin.cn)

这篇文章我们要去做直尺的变换,包括旋转,缩放,平移以及拉伸(刻度的伸长和缩短)

做的过程中踩过的坑挺多的......

这里我们采用的是自定义一个TransfromLayout布局,在这布局里面去包含RulerView

为什么这里不直接去改变RulerView呢,还要去嵌套一层layout呢?因为变换的时候,除了RulerView要变换外,旋转按钮以及拉伸按钮的位置也要跟着变换,用一个layout包裹着他们,layout变换的时候它的子控件也会一起跟着变换

重点:

视图平移的时候我们可能会想到设置ViewGroup的LayoutParams的leftMargin和topMargin,然后更新视图的LayoutParams,或者ViewGroup的layout()方法

视图的缩放可以采用改变LayoutParams的width和height

Layout也是View的一种,视图的旋转采用view.setRotation()

这时候问题就来了,这么多种方式,我该采用哪一种呢,混着来么,会有什么后果吗?

答案是有的,并且会为后续的开发留下坑,就比如我一开始平移使用的是改变LayoutParams的leftMargin和topMargin,旋转使用的是view.setRotation(),很好,功能也完成了,可是后面出了个新需求,要求落点在直尺上边的50px范围内,手指滑动绘制的时候是一条直线,如下图

此时,如果直尺会经过一系列的变换,例如平移,旋转,缩放,落点跟直尺的距离判断将变得非常的麻烦。

所以我后面对这些变换统一采用的是view的属性动画

  • 平移 view.setTranslationX和view.setTranslationY
  • 缩放 view.setScaleX和view.setScaleY
  • 旋转 view.setRotation

采用属性动画后,我们可以打开开发者选项的显示边界布局

移动下直尺 可以看到,图像的布局的边界位置还是在原来的地方,可是图像的渲染的位置改变了

我们点进去view.setTranslationX的源码看一下

可以看到,这里并没有对布局的layoutParams做处理,只是对图形的渲染的节点(mRenderNode)做了处理,所以图形的实际位置没有改变,只不过是渲染的位置做了变化,里面有个matrix记录着图形的各种变化,当我们手指按下的时候,我们可以通过视图matrix的逆矩阵,去映射落点,然后判断落点去直尺的距离,这样就会简单很多

并且官方也是这么操作的(手指落下,判断点是否在图像内部) 好了,废话讲了这么多,该进入正题了

二、平移

平移这里我采用的是GestureDetector手势

kotlin 复制代码
class TransformLayout @JvmOverloads constructor(
    private val mContext: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : RelativeLayout(mContext, attrs, defStyleAttr) {

    private var lastX: Float = 0f
    private var lastY: Float = 0f
    private var dX: Float = 0f
    private var dY: Float = 0f


    //手势监听
    private val gestureDetector = GestureDetector(mContext, GestureListener())

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

    inner class GestureListener: GestureDetector.SimpleOnGestureListener() {
        override fun onDown(e: MotionEvent): Boolean {
            lastX = e.rawX
            lastY = e.rawY
            return true
        }
        override fun onScroll(
            e1: MotionEvent,
            e2: MotionEvent,
            distanceX: Float,
            distanceY: Float
        ): Boolean {
            //获取当前实时点信息
            val rawX = e2.rawX
            val rawY = e2.rawY

            //变化量
            dX = rawX - lastX
            dY = rawY - lastY


            this@TransformLayout.translationX +=dX
            this@TransformLayout.translationY +=dY


            //更新最后屏幕点信息
            lastX = rawX
            lastY = rawY

            return true
        }
    }
}

主要就是获得平移的变化量,然后设置给layout的translationX和translationY

三、缩放

缩放是两指捏合进行缩放,这里采用的是ScaleGestureDetector手势的SimpleOnScaleGestureListener()

kotlin 复制代码
class TransformLayout @JvmOverloads constructor(
    private val mContext: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : RelativeLayout(mContext, attrs, defStyleAttr) {

    ......
    
    private var scaleFactor = 1.0f // 初始缩放比例为 1.0


    //缩放监听
    private val scaleGestureDetector = ScaleGestureDetector(mContext, ScaleListener())

    //手势监听
    private val gestureDetector = GestureDetector(mContext, GestureListener())

    override fun onTouchEvent(event: MotionEvent): Boolean {
        return scaleGestureDetector.onTouchEvent(event) or gestureDetector.onTouchEvent(event)
    }

    inner class GestureListener: GestureDetector.SimpleOnGestureListener() {
        ......
    }

    inner class ScaleListener: ScaleGestureDetector.SimpleOnScaleGestureListener() {
        /**
         * 缩放进行中,返回值表示是否下次缩放需要重置,如果返回ture,那么detector就会重置缩放事件,如果返回false,detector会在之前的缩放上继续进行计算
         */
        override fun onScale(detector: ScaleGestureDetector): Boolean {
            scaleFactor *= detector.scaleFactor
            scaleFactor = java.lang.Float.max(0.5f, min(scaleFactor, 2f)) // 设置最大和最小缩放比例
            this@TransformLayout.scaleY = scaleFactor
            this@TransformLayout.scaleX = scaleFactor
            return false
        }

        /**
         * 缩放开始,返回值表示是否受理后续的缩放事件
         */
        override fun onScaleBegin(detector: ScaleGestureDetector): Boolean {
            return true
        }

        override fun onScaleEnd(detector: ScaleGestureDetector) {}

    } 
}

四、旋转

旋转我这里做的是单指按住旋转的按钮进行旋转操作

kotlin 复制代码
class TransformLayout @JvmOverloads constructor(
    private val mContext: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : RelativeLayout(mContext, attrs, defStyleAttr) {

   ......
    private var layoutDegree = 0f
    private var oriX = 0f
    private var oriY = 0f

    //缩放监听
    private val scaleGestureDetector = ScaleGestureDetector(mContext, ScaleListener())

    //手势监听
    private val gestureDetector = GestureDetector(mContext, GestureListener())

    override fun onTouchEvent(event: MotionEvent): Boolean {
        return scaleGestureDetector.onTouchEvent(event) or gestureDetector.onTouchEvent(event)
    }

    inner class GestureListener: GestureDetector.SimpleOnGestureListener() {
        ......
    }

    inner class ScaleListener: ScaleGestureDetector.SimpleOnScaleGestureListener() {
        ......

    }

    fun rotateLayout(event: MotionEvent) {
        when (event.actionMasked) {
            MotionEvent.ACTION_DOWN -> {
                oriX = event.x
                oriY = event.y
                layoutDegree = rotation
            }
            MotionEvent.ACTION_MOVE -> {
                val tempRawX = event.x
                val tempRawY = event.y
                val first = Point(oriX.toInt(), oriY.toInt())
                val second = Point(tempRawX.toInt(), tempRawY.toInt())
                val cen = Point(width / 2, height / 2)
                //旋转
                val angle = angle(cen, first, second)
                layoutDegree += angle
                rotation = layoutDegree
            }
            MotionEvent.ACTION_UP -> {}
            else -> {}
        }
    }

    private fun angle(cen: Point, first: Point, second: Point): Float {
        val dx1: Float = (first.x - cen.x).toFloat()
        val dy1: Float = (first.y - cen.y).toFloat()
        val dx2: Float = (second.x - cen.x).toFloat()
        val dy2: Float = (second.y - cen.y).toFloat()

        // 计算三边的平方
        val ab2 =
            ((second.x - first.x) * (second.x - first.x) + (second.y - first.y) * (second.y - first.y)).toFloat()
        val oa2 = dx1 * dx1 + dy1 * dy1
        val ob2 = dx2 * dx2 + dy2 * dy2

        // 根据两向量的叉乘来判断顺逆时针
        val isClockwise =
            (first.x - cen.x) * (second.y - cen.y) - (first.y - cen.y) * (second.x - cen.x) > 0
        // 根据余弦定理计算旋转角的余弦值
        var cosDegree =
            (oa2 + ob2 - ab2) / (2 * Math.sqrt(oa2.toDouble()) * Math.sqrt(ob2.toDouble()))

        // 异常处理,因为算出来会有误差绝对值可能会超过一,所以需要处理一下
        if (cosDegree > 1) {
            cosDegree = 1.0
        } else if (cosDegree < -1) {
            cosDegree = -1.0
        }

        // 计算弧度
        val radian = acos(cosDegree)

        // 计算旋转过的角度,顺时针为正,逆时针为负
        return (if (isClockwise) Math.toDegrees(radian) else -Math.toDegrees(radian)).toFloat()
    }

}
ini 复制代码
//这里的totateView指的是上面箭头所指的View
//这里需要将触摸位置进行偏移,因为是通过触摸rotateView去旋转transformLayout,并不是旋转rotateView自身
rotateView.setOnTouchListener { v, event ->
    val offsetX: Float = (rotateView.left-transformLayout.scrollX).toFloat()
    val offsetY: Float = (rotateView.top-transformLayout.scrollY).toFloat()
    event.offsetLocation(offsetX, offsetY)

    transformLayout.pivotX = transformLayout.left+transformLayout.width/2f
    transformLayout.pivotY = transformLayout.top+transformLayout.height/2f
    transformLayout.rotateLayout(event)
    true
}

五、拉伸

拉伸这里就没办法用属性动画了,因为我们确实要改变图形的宽度

csharp 复制代码
//addLengthBtn是上面箭头所指的+
addLengthBtn.setOnTouchListener { v, event ->
    when (event.actionMasked) {
        MotionEvent.ACTION_DOWN -> {
            lastX = event.rawX.toInt()
        }
        MotionEvent.ACTION_MOVE -> {
            //获取当前实时点信息
            val rawX = event.rawX.toInt()
            //变化量
            dX = rawX - lastX
            val params = transformLayout.layoutParams as LayoutParams
            //这里指定下宽高,否则如果布局文件指定了match_parent/wrap_content,会导致自动伸缩宽高
            params.width = transformLayout.measuredWidth + dX
            params.height = transformLayout.measuredHeight
            transformLayout.layoutParams = params
        }
        MotionEvent.ACTION_UP -> {}
        else -> {}
    }
    return@setOnTouchListener true
}

六、注意的点

视图的默认摆放位置不要在xml里面去指定,不然后面对于落点坐标的位置进行逆矩阵映射会有影响

之前就是在xml里面去默认让视图居中,结果后面判断落点位置的时候一直不准确,有谁知道为什么会这样的吗?排查了一天才排查出来,要将这个android:layout_centerInParent="true"去掉,在代码里面setTranslateX和setTranslateY去改变初始位置

相关推荐
大白要努力!2 分钟前
Android opencv使用Core.hconcat 进行图像拼接
android·opencv
天空中的野鸟1 小时前
Android音频采集
android·音视频
小白也想学C2 小时前
Android 功耗分析(底层篇)
android·功耗
曙曙学编程2 小时前
初级数据结构——树
android·java·数据结构
闲暇部落4 小时前
‌Kotlin中的?.和!!主要区别
android·开发语言·kotlin
诸神黄昏EX6 小时前
Android 分区相关介绍
android
大白要努力!7 小时前
android 使用SQLiteOpenHelper 如何优化数据库的性能
android·数据库·oracle
Estar.Lee7 小时前
时间操作[取当前北京时间]免费API接口教程
android·网络·后端·网络协议·tcp/ip
Winston Wood8 小时前
Perfetto学习大全
android·性能优化·perfetto
Dnelic-11 小时前
【单元测试】【Android】JUnit 4 和 JUnit 5 的差异记录
android·junit·单元测试·android studio·自学笔记