0%

Android自定义View-平移缩放旋转

概述

上篇文章写了几种支持 高亮展示固定行并滑动文本的自定义View, 效果如下:

这篇文章再来写一个比较有意思的自定义 View, 我想大家在工作中应该基本上都写过类似的~一个支持手指平移,缩放,旋转的控件:

在介绍实现方案之前,先复习一下相关的知识。

MotionEvent

在 MotionEvent 中有许多事件类型,跟这篇文章相关的事件就是手指在屏幕上滑动,与之相关的事件如下:

  • ACTION_DOWN
  • ACTION_MOVE
  • ACTION_UP

这个手指滑动的动作会产生一系列事件,称之为事件流: ACTION_DOWN -> ACTION_MOVE -> ACTION_MOVE -> ... -> ACTION_MOVE -> ACTION_UP

平移控件的话便是接收到这些事件后,通过计算拿到偏移量,然后进行 translation 即可。

再看看 MotionEvent 相关的两种坐标方法:

  • getX/Y(): 获取触摸点距离自身 View 左/上边界的距离。因此如果控件跟随手指移动后,其 getX/Y() 方法获取的坐标值依旧是距离自身边界的距离,不会变。
  • getRawX/Y(): 获取触摸点距离屏幕左/上边界的距离。如果控件跟随手指移动,那该方法获取的值会随之变化。

平移

关于 Android 的坐标体系就不赘述了,估计大家都看过网上的那些坐标系图。这里看看 View 的几个方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public final int getWidth() {
return mRight - mLeft;
}

public final int getHeight() {
return mBottom - mTop;
}

public float getX() {
return mLeft + getTranslationX();
}

public float getY() {
return mTop + getTranslationY();
}

View 的位置及大小由 left, top, right, bottom 四个参数决定,且这四个参数都是相对于其父 View 的。另外 View.getX/Y() 方法表示 View 以其父 View 左上角为坐标原点的 x, y 值,而 setTranslationX/Y() 用于平移 View, 默认为 0, 因此 x/y 默认等于 left/top。

接下来介绍一下几种平移 View 的方式:

layout(), offsetLeftAndRight(), offsetTopAndBottom()

1
2
3
4
5
layout(left + dx, top + dy, right + dx, bottom + dy)

// 相当于
offsetLeftAndRight(dx)
offsetTopAndBottom(dy)

这种方式会直接改变 View 的 left, top, right, bottom 等布局参数。

setX(), setY(), setTranslationX(), setTranslationY()

1
2
3
4
5
6
7
public void setX(float x) {
setTranslationX(x - mLeft);
}

public void setY(float y) {
setTranslationY(y - mTop);
}

根据上面对 x/y 和 translationX/Y 的关系,可以知道这两种方式原理是一样的。它们都会改变 View 的 translation 值,但是没有修改布局参数 left, right, top, bottom 等。

属性动画对于平移的操作其实也是改变的这个属性,我们知道属性动画和补间动画的一个区别就是补间动画改变了 View 的显示位置,但是事件响应区域还是在之前的地方;而属性动画除了改变了 View 的显示位置,其事件响应区域也会对应改变。这里在于对属性动画的操作,显示效果变化的原因是 canvas matrix 做了变换,而依旧能响应事件的原因是事件分发的时候也会做对应的映射,使得事件能够响应在变化后的区域

scrollTo(), scrollBy()

这两个方法在上篇文章已经看过了,它影响的是 View 的内容,即影响画布 canvas 的位移,因此可以这样平移:

1
(parent as? View)?.scrollBy(-dx, -dy)

它也会对应改变事件响应的区域。

旋转

旋转使用 View.setRotation() 方法,跟修改 translationX 一样,它也不改变布局参数 left, right, top, bottom 等,旋转的属性动画也是改变的这个属性。注意旋转后 View.getLocationOnScreen() 等返回的 View 的位置坐标还是以 View 原先左上角返回的旋转后的坐标值。

跟旋转相关的有一个中心点 pivot 参数: setPivotX/Y(), 这个参数表示旋转的中心点,默认是 View 中心。

缩放

缩放使用 View.setScaleX/Y() 方法,它也不改变布局参数 left, right, top, bottom 等,因此即使缩放后看上去 View 变大或变小了,但其 getWidth() 和 getHeight() 方法返回的长宽还是不变的,而事件响应区域则变化了,因为事件分发做了映射。

同样也需要通过 setPivotX/Y() 设置中心点。

自定义View实现

具体效果在上图中可以看到,相关代码在文章末尾。这个控件有两种形式,一种是大面板,一种是收起后的小面板。

  • 面板的展开和收起使用缩放实现
  • 大面板顶部中间可以触摸平移,收起后小面板也可以移动且会自动吸附到边界
  • Activity 固定竖屏,但是控件可以感应横竖屏,使用 View 的旋转实现

手指平移

手指平移需要重写 onTouchEvent 方法,计算手指移动的距离,进而移动控件。这里需要根据 down 事件的坐标来判断是不是触摸到了大面板的移动按钮或者是小面板,只有这两个 View 能响应平移事件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
override fun onTouchEvent(event: MotionEvent?): Boolean {
// 触摸的不是拖动控件则直接返回
if (event == null || (event.action == MotionEvent.ACTION_DOWN && !touchInTransView(event) && !touchInLittlePanel(event))) {
// 如果是大面板在展示,则拦截触摸事件,不交给下面的控件去响应了
return inBigPanel()
}
if (isBigDrag || touchInTransView(event)) {
// 处理大面板拖动
return onBigPanelTouchEvent(event)
}
if (isLittleDrag || touchInLittlePanel(event)) {
// 处理小面板拖动
return onLittlePanelTouchEvent(event)
}
return inBigPanel()
}

接下来以大面板平移为例,看看实现代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
private fun onBigPanelTouchEvent(event: MotionEvent): Boolean {
val x = event.x
val y = event.y
when (event.action) {
MotionEvent.ACTION_DOWN -> {
// 记录 down 的坐标
lastX = x
lastY = y
isBigDrag = true
}
MotionEvent.ACTION_MOVE -> {
// 计算平移距离
val dx = x - lastX
val dy = y - lastY
// 处理横竖屏下,平移量的计算
when {
isPortrait() -> {
tranX += dx
tranY += dy
}
isLandLeft() -> {
tranX -= dy
tranY += dx
}
else -> {
tranX += dy
tranY -= dx
}
}
// 纠正边界,并平移
correctBigBorder()
}
MotionEvent.ACTION_UP -> {
isBigDrag = false
}
}
return true
}

注意上面用的是 MotionEvent.getX/Y() 方法,这里也可以使用 MotionEvent.getRawX/Y() 方法,但是需要在每次 move 事件的时候更新 lastX/Y 值,具体原因参考上面关于这俩方法的解析。上面代码都有写注释,主要看看 correctBigBorder() 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
private fun correctBigBorder() {
val lMax = leftMax()
if (tranX < lMax) {
tranX = lMax
}
val rMax = rightMax()
if (tranX > rMax) {
tranX = rMax
}
val tMax = topMax()
if (tranY < tMax) {
tranY = tMax
}
val bMax = bottomMax()
if (tranY > bMax) {
tranY = bMax
}
// 设置 View 的 translation 属性,此处是真正平移的代码
translationX = tranX
translationY = tranY
}

上面在 move 事件的时候,需要判断是否超出了边界,超出了则纠正,然后进行 View 的平移,上面的几个边界 max 方法是在横竖屏(旋转)状态下计算出的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
/**
* 大面板滑动的左部边界
* [getTranslationX] = [getX] - [getLeft]
*/
private fun leftMax() = if (isPortrait()) marginL - left else (height() - width()) / 2 + marginL - left

/**
* 大面板滑动的顶部边界
* [getTranslationY] = [getY] - [getTop]
*/
private fun topMax() = if (isPortrait()) marginT - top else marginT - top - (height() - width()) / 2

/**
* 大面板滑动的右部边界
* [getTranslationX] = [getX] - [getLeft]
*/
private fun rightMax() = maxWidth() - orientedWidth() + leftMax()

/**
* 大面板滑动的底部边界
* [getTranslationY] = [getY] - [getTop]
*/
private fun bottomMax(real: Boolean = true) = maxHeight() - orientedHeight() + topMax()

private fun width() = parentWidth * 0.6f

private fun height() = parentHeight * 0.4f

/**
* 区分横竖屏方向后的面板宽度
*/
private fun orientedWidth() = if (isPortrait()) width() else height()

/**
* 区分横竖屏方向后的面板高度
*/
private fun orientedHeight() = if (isPortrait()) height() else width()

/**
* 可展示区域的宽度
*/
private fun maxWidth() = parentWidth - marginL - marginR

/**
* 可展示区域的高度
*/
private fun maxHeight() = parentHeight - marginT - marginB

横竖屏感应(旋转)

由于 Activity 是固定竖屏的,因此控件需要自身感应横竖屏,然后进行旋转:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
private val orientationListener: OrientationEventListener by lazy {
object : OrientationEventListener(context, SensorManager.SENSOR_DELAY_NORMAL) {
override fun onOrientationChanged(orientation: Int) {
val cur = if (orientation >= 330 || orientation < 30) {
// 竖屏向下
Surface.ROTATION_0
} else if (orientation in 60..119) {
// 横屏向右
Surface.ROTATION_90
} else if (orientation in 150..209) {
// 竖屏向上
Surface.ROTATION_180
} else if (orientation in 240..299) {
// 横屏向左
Surface.ROTATION_270
} else {
Surface.ROTATION_0
}
// 没有正在展开、收起面板 && 处于展开大面板状态 && 有屏幕旋转
if (!isFolding && inBigPanel() && this@FancyPanel.orientation != cur) {
this@FancyPanel.orientation = cur
// 设置旋转中心为控件中心点
pivotX = width() / 2f
pivotY = height() / 2f
// 旋转不同的角度
rotation = when {
isPortrait() -> 0f
isLandLeft() -> 90f
else -> 270f
}
// 仅竖屏下展示收起面板的按钮
if (isPortrait()) {
foldView.show()
} else {
foldView.hide()
}
// 纠正边界,防止旋转后溜出屏幕
correctBigBorder()
}
}
}
}

旋转是通过设置 rotate 属性来实现的。小面板模式下没做横竖屏旋转,如果要支持的话,需要设置旋转中心点 pivot 值,且需要重新计算一下对应的 transX 和 transY 值,因为这会影响平移边界的计算。

展开收起面板(缩放)

面板的展开和收起是通过缩放 View 实现的,以收起为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// 收起面板
private fun fold() {
if (isFolding) {
return
}
isFolding = true
// 纠正缩放的中心点
correctPivot(isBigPanelInLeft())
val xScaleAnim = ObjectAnimator.ofFloat(bigPanel, "scaleX", littlePanelSize / width())
val yScaleAnim = ObjectAnimator.ofFloat(bigPanel, "scaleY", littlePanelSize / height())
val animSet = AnimatorSet()
animSet.playTogether(xScaleAnim, yScaleAnim)
animSet.duration = 500
animSet.addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator?) {
// 收起面板后,将小面板吸附到左/右边缘
adjustPanelAttach(isBigPanelInLeft())
littlePanel.show()
isFolding = false
}
})
animSet.start()
}

/**
* 纠正展开和收起大面板时缩放动画的中心
*/
private fun correctPivot(inLeft: Boolean) {
if (inLeft) {
bigPanel.pivotX = 0f
bigPanel.pivotY = 0f
} else {
bigPanel.pivotX = width()
bigPanel.pivotY = 0f
}
}

/**
* 自适应小面板在大面板中的偏移
*/
private fun adjustPanelAttach(inLeft: Boolean) {
if (inLeft) {
littlePanel.x = 0f
littlePanel.y = 0f
x = littlePanelPadding
} else {
littlePanel.x = width() - littlePanelSize
littlePanel.y = 0f
x = parentWidth - littlePanelPadding - width()
}
tranX = x - left
}

上面主要有两个地方需要注意一下:

  • correctPivot: 纠正展开和收起大面板时缩放动画的中心,这里根据面板靠近屏幕左侧还是右侧,将缩放中心点设置在 View 左边还是右边。
  • adjustPanelAttach: 收起为小面板后,将面板吸附到左/右边界展示。

总结

对于平移,旋转以及缩放,我们在平时开发中应该都会碰到,这篇文章通过这个控件来学习一下相关的操作,代码已经传到了 github 上,有更好实现方式的同学欢迎提建议~