仿MIUI实现带弹性的ScrollView

一、前言

用过MIUI系统的都知道,拖动列表时如果到了顶部或者底部就会有一个弹性的效果,这里就自己动手去实现一个带弹性效果的ScrollView。最后效果如下。
图片描述

二、具体实现

具体实现过程大体上就是需要去监听用户当前手势,也就是新建一个RubberScrollView类,先继承ScrollView,然后重写onInterceptTouchEvent方法和OnTouchEvent方法。先在OnInterceptTouchEvent拦截相关的事件,这里定义两个boolean变量isRestoring表示当前View是否恢复原状,isBeingDragged表示当前View是否被拖动,如果当前视图不可点击 或者视图已经恢复原状 或者既不在顶部也不在底部,就把onInterceptEvent交给父类的该方法去处理,然后定义一个最小的滑动距离mTouchSlop,计算当前视图到达顶部或者底部后滑动的距离的距离,如果距离的绝对值(上拉时这个距离是为负值的,并且在视图没有填充满时为即在底部也在顶部)大于mTouchSlop,并且未被拖动,就把isBeingDragged设置为true;如果当前视图并不是在底部或者底部,就照样交给父类的onInterceptEvent方法去处理。
上面的过程如下代码

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
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
nt action = MotionEventCompat.getActionMasked(event);
if (isRestoring && action == MotionEvent.ACTION_DOWN) {
isRestoring = false;
}
if (!isEnabled() || isRestoring || (!isScrollToTop() && !isScrollToBottom())) {
return super.onInterceptTouchEvent(event);
}
switch (action) {
case MotionEvent.ACTION_DOWN: {
mActivePointerId = event.getPointerId(0);
isBeingDragged = false;
float initialMotionY = getMotionEventY(event);
if (initialMotionY == -1) {
return super.onInterceptTouchEvent(event);
}
mInitialMotionY = initialMotionY;
break;
}
case MotionEvent.ACTION_MOVE: {
if (mActivePointerId == MotionEvent.INVALID_POINTER_ID) {
return super.onInterceptTouchEvent(event);
}
final float y = getMotionEventY(event);
if (y == -1f) {
return super.onInterceptTouchEvent(event);
}
if (isScrollToTop() && !isScrollToBottom()) {
// 在顶部不在底部
float yDiff = y - mInitialMotionY;
if (yDiff > mTouchSlop && !isBeingDragged) {
isBeingDragged = true;
}
} else if (!isScrollToTop() && isScrollToBottom()) {
// 在底部不在顶部
float yDiff = mInitialMotionY - y;
if (yDiff > mTouchSlop && !isBeingDragged) {
isBeingDragged = true;
}
} else if (isScrollToTop() && isScrollToBottom()) {
// 在底部也在顶部
float yDiff = y - mInitialMotionY;
if (Math.abs(yDiff) > mTouchSlop && !isBeingDragged) {
isBeingDragged = true;
}
} else {
// 不在底部也不在顶部
return super.onInterceptTouchEvent(event);
}
break;
}
case MotionEventCompat.ACTION_POINTER_UP:
/**
* 多指触控情况下触控点的信息,根据该信息
* 获取手指接触递点的id
*/
onSecondaryPointerUp(event);
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
mActivePointerId = MotionEvent.INVALID_POINTER_ID;
isBeingDragged = false;
break;
}
return isBeingDragged || super.onInterceptTouchEvent(event);
}

在ViewGroup中事件的传递是从dispatchTouchEvent()->OnInterceptEvent()->OnTouchEvent()进行事件分发的,在这里进行了处理后再到OnTouchEvent里面进行处理,

如果滑动到顶部,就用当前纵坐标减去初始时的Y坐标,如果这个距离小于0,就说明当前未滑动或者滑动无效,就把当前事件交给OnTouchEvent去处理,大于0就交给下面的方法进行计算

1
2
3
4
5
6
7
8
9
10
/**
* 根据滑动的距离计算缩放的比例
*/
private float calculateRate(float distance) {
float originalDragPercent = distance / (getResources().getDisplayMetrics().heightPixels);
float dragPercent = Math.min(1f, originalDragPercent);
// 二次函数 滑动距离越大,缩放比例越小,形成弹性效果
float rate = 2f * dragPercent - (float) Math.pow(dragPercent, 2f);
return 1 + rate / 5f;
}

然后把计算得到的结果传入pull()方法;同理,滑动到底部时也进行计算,把最后的结果传入到push()方法里面;如果即在顶部也在底部,就根据移动的距离计算的结果,如果为正值就传入pull方法,是负值就传入push方法,如下

1
2
3
4
5
6
7
8
9
private void pull(float scale) {
this.setPivotY(0);
this.setScaleY(scale);
}
private void push(float scale) {
this.setPivotY(this.getHeight());
this.setScaleY(scale);
}

pull时setPivot()为屏幕的顶端,push时则为屏幕的底部。
然后计算坐标看下这个方法,当前手指id的索引用findPointerIndex方法获取在安卓2.0之前是为-1的,所以这里判断下,拦截事件时也需要进行判断。

1
2
3
4
5
6
private float getMotionEventY(MotionEvent event) {
// 获取当前手指id的索引
int index = event.findPointerIndex(mActivePointerId);
// 得到触摸点相对于控件的Y坐标
return index < 0 ? -1f : event.getY(index);
}

最后看看OnTouchEvent()方法的的代码

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
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (MotionEventCompat.getActionMasked(event)) {
case MotionEvent.ACTION_DOWN:
mActivePointerId = event.getPointerId(0);
isBeingDragged = false;
break;
case MotionEvent.ACTION_MOVE: {
float y = getMotionEventY(event);
if (isScrollToTop() && !isScrollToBottom()) {
// 在顶部不在底部
mDistance = y - mInitialMotionY;
if (mDistance < 0) {
return super.onTouchEvent(event);
}
mScale = calculateRate(mDistance);
pull(mScale);
return true;
} else if (!isScrollToTop() && isScrollToBottom()) {
// 在底部不在顶部
mDistance = mInitialMotionY - y;
if (mDistance < 0) {
return super.onTouchEvent(event);
}
mScale = calculateRate(mDistance);
push(mScale);
return true;
} else if (isScrollToTop() && isScrollToBottom()) {
// 在底部也在顶部
mDistance = y - mInitialMotionY;
if (mDistance > 0) {
mScale = calculateRate(mDistance);
pull(mScale);
} else {
mScale = calculateRate(-mDistance);
push(mScale);
}
return true;
} else {
// 不在底部也不在顶部
return super.onTouchEvent(event);
}
}
case MotionEventCompat.ACTION_POINTER_DOWN:
mActivePointerId = event.getPointerId(MotionEventCompat.getActionIndex(event));
break;
case MotionEventCompat.ACTION_POINTER_UP:
onSecondaryPointerUp(event);
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL: {
if (isScrollToTop() && !isScrollToBottom()) {
animateRestore(true);
} else if (!isScrollToTop() && isScrollToBottom()) {
animateRestore(false);
} else if (isScrollToTop() && isScrollToBottom()) {
if (mDistance > 0) {
animateRestore(true);
} else {
animateRestore(false);
}
} else {
return super.onTouchEvent(event);
}
break;
}
}
return super.onTouchEvent(event);
}

主要的逻辑上面都已经说了,最后就是ACTION_CANCEL里面根据当前滑动的情况后在animateRestore()进行具体的动画过程了。
看看下面的代码

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
/**
* 具体动画
* @param isPullRestore
*/
private void animateRestore(final boolean isPullRestore) {
ValueAnimator animator = ValueAnimator.ofFloat(mScale, 1f);
animator.setDuration(300);
// 设置减速插补器
animator.setInterpolator(new DecelerateInterpolator(2f));
animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
float value = (float) animation.getAnimatedValue();
if (isPullRestore) {
pull(value);
} else {
push(value);
}
}
});
animator.addListener(new Animator.AnimatorListener() {
@Override
public void onAnimationStart(Animator animation) {
isRestoring = true;
}
@Override
public void onAnimationEnd(Animator animation) {
isRestoring = false;
}
@Override
public void onAnimationCancel(Animator animation) {
}
@Override
public void onAnimationRepeat(Animator animation) {
}
});
animator.start();

这里逻辑比较简单,在addUpdateListener里面进行动画的实时更新,也就是达到跟着用户的手势View进行缩放的效果,然后addListener里面就是在动画结束时设置isRestoring的值,这样在事件触发时就会跳过,动画也就会据此执行或者不执行。这样全部过程就完成了,使用的话就直接当作普通的ScrollView使用,并且用同样的原理也能写一个带弹性效果的RecyclerView。
最后附上全部的代码

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
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
package com.legend.ffpmvp.common.view;
/**
* @author Legend
* @data by on 2018/3/1.
* @description
*/
import android.animation.Animator;
import android.animation.ValueAnimator;
import android.content.Context;
import android.support.v4.view.MotionEventCompat;
import android.support.v4.view.ViewCompat;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.ViewConfiguration;
import android.view.animation.DecelerateInterpolator;
import android.widget.ScrollView;
public class RubberScrollView extends ScrollView {
/**
* 是否恢复原状
*/
private boolean isRestoring;
/**
* 手指接触点id
*/
private int mActivePointerId;
/**
* 手指按下时y坐标
*/
private float mInitialMotionY;
/**
* 是否被拖动
*/
private boolean isBeingDragged;
/**
* 缩放比例
*/
private float mScale;
/**
* 滑动的距离
*/
private float mDistance;
/**
* 最小移动距离
*/
private int mTouchSlop;
public RubberScrollView(Context context, AttributeSet attrs) {
super(context, attrs);
mTouchSlop = ViewConfiguration.get(getContext()).getScaledTouchSlop();
}
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
int action = MotionEventCompat.getActionMasked(event);
if (isRestoring && action == MotionEvent.ACTION_DOWN) {
isRestoring = false;
}
if (!isEnabled() || isRestoring || (!isScrollToTop() && !isScrollToBottom())) {
return super.onInterceptTouchEvent(event);
}
switch (action) {
case MotionEvent.ACTION_DOWN: {
mActivePointerId = event.getPointerId(0);
isBeingDragged = false;
float initialMotionY = getMotionEventY(event);
if (initialMotionY == -1) {
return super.onInterceptTouchEvent(event);
}
mInitialMotionY = initialMotionY;
break;
}
case MotionEvent.ACTION_MOVE: {
if (mActivePointerId == MotionEvent.INVALID_POINTER_ID) {
return super.onInterceptTouchEvent(event);
}
final float y = getMotionEventY(event);
if (y == -1f) {
return super.onInterceptTouchEvent(event);
}
if (isScrollToTop() && !isScrollToBottom()) {
// 在顶部不在底部
float yDiff = y - mInitialMotionY;
if (yDiff > mTouchSlop && !isBeingDragged) {
isBeingDragged = true;
}
} else if (!isScrollToTop() && isScrollToBottom()) {
// 在底部不在顶部
float yDiff = mInitialMotionY - y;
if (yDiff > mTouchSlop && !isBeingDragged) {
isBeingDragged = true;
}
} else if (isScrollToTop() && isScrollToBottom()) {
// 在底部也在顶部
float yDiff = y - mInitialMotionY;
if (Math.abs(yDiff) > mTouchSlop && !isBeingDragged) {
isBeingDragged = true;
}
} else {
// 不在底部也不在顶部
return super.onInterceptTouchEvent(event);
}
break;
}
case MotionEventCompat.ACTION_POINTER_UP:
onSecondaryPointerUp(event);
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
mActivePointerId = MotionEvent.INVALID_POINTER_ID;
isBeingDragged = false;
break;
}
return isBeingDragged || super.onInterceptTouchEvent(event);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (MotionEventCompat.getActionMasked(event)) {
case MotionEvent.ACTION_DOWN:
mActivePointerId = event.getPointerId(0);
isBeingDragged = false;
break;
case MotionEvent.ACTION_MOVE: {
float y = getMotionEventY(event);
if (isScrollToTop() && !isScrollToBottom()) {
// 在顶部不在底部
mDistance = y - mInitialMotionY;
if (mDistance < 0) {
return super.onTouchEvent(event);
}
mScale = calculateRate(mDistance);
pull(mScale);
return true;
} else if (!isScrollToTop() && isScrollToBottom()) {
// 在底部不在顶部
mDistance = mInitialMotionY - y;
if (mDistance < 0) {
return super.onTouchEvent(event);
}
mScale = calculateRate(mDistance);
push(mScale);
return true;
} else if (isScrollToTop() && isScrollToBottom()) {
// 在底部也在顶部
mDistance = y - mInitialMotionY;
if (mDistance > 0) {
mScale = calculateRate(mDistance);
pull(mScale);
} else {
mScale = calculateRate(-mDistance);
push(mScale);
}
return true;
} else {
// 不在底部也不在顶部
return super.onTouchEvent(event);
}
}
case MotionEventCompat.ACTION_POINTER_DOWN:
mActivePointerId = event.getPointerId(MotionEventCompat.getActionIndex(event));
break;
case MotionEventCompat.ACTION_POINTER_UP:
onSecondaryPointerUp(event);
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL: {
if (isScrollToTop() && !isScrollToBottom()) {
animateRestore(true);
} else if (!isScrollToTop() && isScrollToBottom()) {
animateRestore(false);
} else if (isScrollToTop() && isScrollToBottom()) {
if (mDistance > 0) {
animateRestore(true);
} else {
animateRestore(false);
}
} else {
return super.onTouchEvent(event);
}
break;
}
}
return super.onTouchEvent(event);
}
private boolean isScrollToTop() {
return !ViewCompat.canScrollVertically(this, -1);
}
private boolean isScrollToBottom() {
return !ViewCompat.canScrollVertically(this, 1);
}
private float getMotionEventY(MotionEvent event) {
// 获取当前手指id的索引
int index = event.findPointerIndex(mActivePointerId);
// 得到触摸点相对于控件的Y坐标
return index < 0 ? -1f : event.getY(index);
}
private void onSecondaryPointerUp(MotionEvent event) {
final int pointerIndex = MotionEventCompat.getActionIndex(event);
final int pointerId = event.getPointerId(pointerIndex);
if (pointerId == mActivePointerId) {
int newPointerIndex = pointerIndex == 0 ? 1 : 0;
mActivePointerId = event.getPointerId(newPointerIndex);
}
}
/**
* 根据滑动的距离计算缩放的比例
*/
private float calculateRate(float distance) {
float originalDragPercent = distance / (getResources().getDisplayMetrics().heightPixels);
float dragPercent = Math.min(1f, originalDragPercent);
// 二次函数 使拖动到底部或顶部时产生弹性效果
float rate = 2f * dragPercent - (float) Math.pow(dragPercent, 2f);
return 1 + rate / 5f;
}
/**
* 具体动画
* @param isPullRestore
*/
private void animateRestore(final boolean isPullRestore) {
ValueAnimator animator = ValueAnimator.ofFloat(mScale, 1f);
animator.setDuration(300);
// 设置减速插补器
animator.setInterpolator(new DecelerateInterpolator(2f));
animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
float value = (float) animation.getAnimatedValue();
if (isPullRestore) {
pull(value);
} else {
push(value);
}
}
});
animator.addListener(new Animator.AnimatorListener() {
@Override
public void onAnimationStart(Animator animation) {
isRestoring = true;
}
@Override
public void onAnimationEnd(Animator animation) {
isRestoring = false;
}
@Override
public void onAnimationCancel(Animator animation) {
}
@Override
public void onAnimationRepeat(Animator animation) {
}
});
animator.start();
}
private void pull(float scale) {
this.setPivotY(0);
this.setScaleY(scale);
}
private void push(float scale) {
this.setPivotY(this.getHeight());
this.setScaleY(scale);
}
}

三、总结

整个过程其实主要就是对事件分发时的监听从而实现对手势的判断,这个实现并不是原创,但是确能加深对整个过程以及事件分发的理解,道阻且长,就不多说了,有理解不对的地方欢迎指出。

参考:事件分发详解

×

纯属好玩

扫码支持
扫码打赏,你说多少就多少

打开支付宝扫一扫,即可进行扫码打赏哦

文章目录
  1. 1. 一、前言
  2. 2. 二、具体实现
  3. 3. 三、总结
,