一、前提
上一篇文章中,我们实现了直尺的绘制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去改变初始位置