Android View 的滚动原理和 Scroller、VelocityTracker 类的使用

Android 开发中经常涉及 View 的滚动,例如类似于 ScrollView 的滚动手势和滚动动画,例如用 ListView 模仿 iOS 上的左滑删除 item,例如 ListView 的下拉刷新。这些都是常见的需求,同时也都涉及 View 滚动的相关知识。

本文将解析 Android 中 View 的滚动原理,并介绍与滚动相关的两个辅助类 ScrollerVelocityTracker,并通过 3 个逐渐深入的例子来加深理解。

注:

  1. 本文没有尝试实现上述几种功能,只阐述基本原理和基础类的使用方法。
  2. 文中的例子只是截取了与 View 相关的代码,完整的示例代码请见DEMO
  3. 本文的源码分析基于 Android API Level 21,并省略掉部分与本文关系不大的代码。

View 的滚动原理

在了解 View 的滚动原理之前,我们先来想象一个场景:我们坐在一个房间里,透过一扇窗户看窗外的风景。窗户是有大小限制的,而风景是没有大小限制的。

把上述的场景对应到 Android 的 View 显示原理上来:当一个 View 显示在界面上,它的上下左右边缘就围成了这个 View 的可视区域,我们可以称这个区域为“可视窗口”,我们平时看到的 View 的内容,都是透过这个可视窗口中看到的“风景”。View 的大小内容可以无穷大,不受可视窗口大小的限制。

另外,如果在窗外的风景中,有一个人出现在窗户右边很远的地方,那么我们在房间里就看不到那个人;如果那个人站在窗户正对着出去的地方,那么我们就可以透过窗户看到他。对应到 View 上面来,只有出现在“可视窗口”中的那部分内容可以被看到。

View 的 scroll 相关

在 View 类中,有两个变量 mScrollXmScrollY,它们记录的是 View 的内容的偏移值。mScrollXmScrollY 的默认值都是 0,即默认不偏移。另外我们需要知道一点,向左滑动,mScrollX 为正数,反正为负数。假设我们令 mScrollX = 10,那么该 View 的内容会相对于原来向左偏移 10px。 看看系统的 View 类中的源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// View.java
public class View {
/**
* The offset, in pixels, by which the content of this view is scrolled
* horizontally.
* {@hide}
*/
protected int mScrollX;
/**
* The offset, in pixels, by which the content of this view is scrolled
* vertically.
* {@hide}
*/
protected int mScrollY;
// ...
}

通常我们比较少直接设置 mScrollXmScrollY,而是通过 View 提供的两个方法来设置。

1
2
// 瞬时滚动到某个位置
public void scrollTo(int x, int y)
1
2
// 瞬时滚动某个距离
public void scrollBy(int x, int y)

看看两个方法的源码:

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
// View.java
/**
* Set the scrolled position of your view. This will cause a call to
* {@link #onScrollChanged(int, int, int, int)} and the view will be
* invalidated.
* @param x the x position to scroll to
* @param y the y position to scroll to
*/
public void scrollTo(int x, int y) {
if (mScrollX != x || mScrollY != y) {
int oldX = mScrollX;
int oldY = mScrollY;
mScrollX = x;
mScrollY = y;
invalidateParentCaches();
onScrollChanged(mScrollX, mScrollY, oldX, oldY);
if (!awakenScrollBars()) {
postInvalidateOnAnimation();
}
}
}
/**
* Move the scrolled position of your view. This will cause a call to
* {@link #onScrollChanged(int, int, int, int)} and the view will be
* invalidated.
* @param x the amount of pixels to scroll by horizontally
* @param y the amount of pixels to scroll by vertically
*/
public void scrollBy(int x, int y) {
scrollTo(mScrollX + x, mScrollY + y);
}

首先看 scrollTo(int x, int y) 方法,它除了设置 mScrollXmScrollY 两个变量,还会触发自己重新绘制,另外还会通过 onScrollChanged 触发回调。而 scrollBy 方法其实也是调用 scrollTo 方法。

明显,两个方法的区别在于 scrollTo 方法是滚动到特定位置,参数 xy 代表“绝对位置”,而 scrollBy 方法是在当前位置基础上滚动特定距离,参数 xy 代表“相对位置”。

另外,View 还提供了 mScrollXmScrollY 的 getter:

1
2
// 获取 mScrollX
public final int getScrollX()
1
2
// 获取 mScrollY
public final int getScrollY()

看看源码中这两个方法的注释,可以更好地理解 scroll 的概念。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// View.java
/**
* Return the scrolled left position of this view. This is the left edge of
* the displayed part of your view. You do not need to draw any pixels
* farther left, since those are outside of the frame of your view on
* screen.
*
* @return The left edge of the displayed part of your view, in pixels.
*/
public final int getScrollX() {
return mScrollX;
}
/**
* Return the scrolled top position of this view. This is the top edge of
* the displayed part of your view. You do not need to draw any pixels above
* it, since those are outside of the frame of your view on screen.
*
* @return The top edge of the displayed part of your view, in pixels.
*/
public final int getScrollY() {
return mScrollY;
}

例子1

为了更好地理解 mScrollXmScrollY,也为后续介绍的知识做准备,我们先看一个例子:

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
/**
* 示例:自定义 ViewGroup,包含几个一字排开的子 View,
* 每个子 View 都与该 ViewGroup 一样大。
* 调用 moveToIndex 方法会调用 scrollTo 方法,从而瞬时滚动到某一位置
*/
public class Case1ViewGroup extends ViewGroup {
public static final int CHILD_NUMBER = 6;
private int mCurrentIndex = 0;
public Case1ViewGroup(Context context) {
super(context);
init();
}
public Case1ViewGroup(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}
public Case1ViewGroup(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
private void init() {
// 添加几个子 View
for (int i = 0; i < CHILD_NUMBER; i++) {
TextView child = new TextView(getContext());
int color;
switch (i % 3) {
case 0:
color = 0xffcc6666;
break;
case 1:
color = 0xffcccc66;
break;
case 2:
default:
color = 0xff6666cc;
break;
}
child.setBackgroundColor(color);
child.setGravity(Gravity.CENTER);
child.setTextSize(TypedValue.COMPLEX_UNIT_SP, 46);
child.setTextColor(0x80ffffff);
child.setText(String.valueOf(i));
addView(child);
}
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int width = MeasureSpec.getSize(widthMeasureSpec);
int height = MeasureSpec.getSize(heightMeasureSpec);
// 每个子 View 都与自己一样大
for (int i = 0, childCount = getChildCount(); i < childCount; i++) {
View childView = getChildAt(i);
childView.measure(
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY));
}
setMeasuredDimension(width, height);
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
// 子 View 一字排开
for (int i = 0, childCount = getChildCount(); i < childCount; i++) {
View childView = getChildAt(i);
childView.layout(getWidth() * i, 0, getWidth() * (i + 1), b - t);
}
}
/**
* 瞬时滚动到第几个子 View
* @param targetIndex 要移动到第几个子 View
*/
public void moveToIndex(int targetIndex) {
if (!canMoveToIndex(targetIndex)) {
return;
}
scrollTo(targetIndex * getWidth(), getScrollY());
mCurrentIndex = targetIndex;
invalidate();
}
/**
* 判断移动的子 View 下标是否合法
* @param index 要移动到第几个子 View
* @return index 是否合法
*/
public boolean canMoveToIndex(int index) {
return index < CHILD_NUMBER && index >= 0;
}
public int getCurrentIndex() {
return mCurrentIndex;
}
}

将以上这个自定义的 ViewGroup 放到 Activity 中,调用它的 moveToIndex(int targetIndex) 就可以实现瞬时滚动到第 n 个子 View 了。(完整示例代码见DEMO

Scroller 类 —— 计算滚动位置的辅助类

到目前为止,我们已经能通过 View 提供的方法设置 mScrollXmScrollY,来使 View “滚动”。但这种滚动都是瞬时的,换句话说,这种滚动都是无动画的。实际上我们想要做到的滚动是平滑的、有动画的,就像我们不希望窗户外面的那个人突然出现在窗户中间,这样会吓到我们,我们更希望那个人能有一个“慢慢走进视觉范围”的过程。

Scroller 类就是帮助我们实现 View 平滑滚动的一个辅助类,使用方法通常是在 View 中作为一个成员变量,用 Scroller 类来记录/计算 View 的滚动位置,再从 Scroller 类中读取出计算结果,设置到 View 中。这里注意一点:在 Scroller 中设置和计算 View 的滚动位置并不会影响 View 的滚动,只有从 Scroller 中取出计算结果并设置到 View 中时,滚动才会实际生效。

Scroller 提供了一系列方法来执行滚动、计算滚动位置,以下列出几个重要方法:

1
2
// 开始滚动,并记下当前时间点作为开始滚动的时间点
public void startScroll(int startX, int startY, int dx, int dy, int duration)
1
2
// 停止滚动
public void abortAnimation()
1
2
// 计算当前时间点对应的滚动位置,并返回动画是否还在进行
public boolean computeScrollOffset()
1
2
// 获取上一次 computeScrollOffset 执行时的滚动 x 值
public final int getCurrX()
1
2
// 获取上一次 computeScrollOffset 执行时的滚动 y 值
public final int getCurrY()
1
2
// 根据当前的时间点,判断动画是否已结束
public final boolean isFinished()

有了这几个方法,我们容易想到如何实现 View 的平滑滚动动画:

  • 在开始动画时调用 startScroll 方法,传入动画开始位置、移动距离、动画时长;
  • 每隔一段时间,调用 computeScrollOffset 方法,计算当前时间点对应的滚动位置;
  • 如果上一步返回 true,代表动画仍在进行,则调用 getCurrXgetCurrY 方法获取当前位置,并调用 View 的 scrollTo 方法使 View 滚动;
  • 不断循环进行第 2 步,直到返回 false,代表动画结束。

这里提到“每隔一段时间”,从直觉上我们可能觉得应该有个循环,但实际上我们可以借助 View 的 computeScroll 方法来实现。先看看 computeScroll 方法的源码:

1
2
3
4
5
6
7
8
9
// View.java
/**
* Called by a parent to request that a child update its values for mScrollX
* and mScrollY if necessary. This will typically be done if the child is
* animating a scroll using a {@link android.widget.Scroller Scroller}
* object.
*/
public void computeScroll() {
}

看注释可知该方法天生就是用来计算 View 的 mScrollXmScrollY 值,该方法会在父 View 调用该 View 的 draw 方法之前被自动调用,View 类中默认没有实现任何内容,我们需要自己实现。所以我们只需要在该方法中,用 Scroller 计算并设置 mScrollXmScrollY 的值,并判断如果动画没结束则让该 View 失效(调用 postInvalidate() 方法),触发下一次 computeScroll,就可以实现上述循环。

例子2

这个例子的 ViewGroup 继承自例子 1 的 ViewGroup,拥有同样的子 View,区别只在于例子 2 是通过 Scroller 来滚动,实现了滚动的动画,而不再是瞬时滚动。

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
/**
* 示例:自定义一个 ViewGroup,包含几个一字排开的子 View,
* 每个子 View 都与该 ViewGroup 一样大。
* 通过 Scroller 实现滚动。
* 调用 moveToIndex 方法会触发 Scroller 的 startScroller,开始动画,并使 View 失效。
* 并在 computeScroll 方法中判断动画是否在进行,进而计算当前滚动位置,并触发下一次 View 失效。
*/
public class Case2ViewGroup extends Case1ViewGroup {
// 滚动器
protected Scroller mScroller;
public Case2ViewGroup(Context context) {
super(context);
initScroller();
}
public Case2ViewGroup(Context context, AttributeSet attrs) {
super(context, attrs);
initScroller();
}
public Case2ViewGroup(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
initScroller();
}
private void initScroller() {
mScroller = new Scroller(getContext());
}
/**
* 通过动画滚动到第几个子 View
* @param targetIndex 要移动到第几个子 View
*/
@Override
public void moveToIndex(int targetIndex) {
if (!canMoveToIndex(targetIndex)) {
return;
}
mScroller.startScroll(
getScrollX(), getScrollY(),
targetIndex * getWidth() - getScrollX(), getScrollY());
mCurrentIndex = targetIndex;
invalidate();
}
public void stopMove() {
if (!mScroller.isFinished()) {
int currentX = mScroller.getCurrX();
int targetIndex = (currentX + getWidth() / 2) / getWidth();
mScroller.abortAnimation();
this.scrollTo(targetIndex * getWidth(), 0);
mCurrentIndex = targetIndex;
}
}
/**
* 在 ViewGroup.dispatchDraw() -> ViewGroup.drawChild() -> View.draw(Canvas,ViewGroup,long) 时被调用
* 任务:计算 mScrollX & mScrollY 应有的值,然后调用scrollTo/scrollBy
*/
@Override
public void computeScroll() {
boolean isNotFinished = mScroller.computeScrollOffset();
if (isNotFinished) {
scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
postInvalidate();
}
}
}

将以上这个自定义的 ScrollerViewGroup 放到 Activity 中,调用它的 moveToIndex(int targetIndex) 就可以实现滚动到第 n 个子 View 了。(在 Activity 中使用的完整示例代码见DEMO

VelocityTracker —— 计算滚动速度的辅助类

到目前为止,我们已经可以实现 View 平滑的滚动动画,那么如果我们还想根据用户手指在 View 上滑动的速度和距离来控制 View 的滚动,应该怎么做?Android 系统提供了另一个辅助类 VelocityTracker 来实现类似功能。

VelocityTracker 是一个速度跟踪器,通过用户操作时(通常在 View 的 onTouchEvent 方法中)传进去一系列的 Event,该类就可以计算出用户手指滑动的速度,开发者可以方便地获取这些参数去做其他事情。或者手指滑动超过一定速度并松手,就触发翻页。

看看 VelocityTracker 类提供的几个常用的方法,这些方法分为几类:

  • 初始化和销毁:

    1
    2
    // 由系统分配一个 VelocityTracker 对象,而不是 new 一个
    static public VelocityTracker obtain()
    1
    2
    - // 使用完毕时调用该方法回收 VelocityTracker 对象
    public void recycle()
  • 添加 Event 以供追踪:

    1
    2
    // 不断调用该方法传入一系列 event,记录用户的操作
    public void addMovement(MotionEvent event)
  • 计算速度:

    1
    2
    // 计算调用该方法的时刻对应的速度,传入的是速度的计时单位
    public void computeCurrentVelocity(int units)
    1
    2
    // 调用 computeCurrentVelocity 方法后就可以通过该方法获取之前计算的 x 方向速度
    public float getXVelocity()
    1
    2
    // 调用 computeCurrentVelocity 方法后就可以通过该方法获取之前计算的 y 方向速度
    public float getYVelocity()

例子3

下面通过一个例子来看看 VelocityTracker 的用法。该例子的 ViewGroup 继承自例子 2 的 ViewGroup,拥有同样的子 View,区别在于除了可以用动画来滚动,还可以用手势来拖动滚动。重点看该 ViewGroup 的 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
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
/**
* 示例:自定义一个 ViewGroup,包含几个一字排开的子 View,
* 每个子 View 都与该 ViewGroup 一样大。
* 通过 VelocityTracker 监控手指滑动速度。
*/
public class Case3ViewGroup extends Case2ViewGroup {
// 速度监控器
private VelocityTracker mVelocityTracker;
public Case3ViewGroup(Context context) {
super(context);
}
public Case3ViewGroup(Context context, AttributeSet attrs) {
super(context, attrs);
}
public Case3ViewGroup(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
// 非滑动状态
private static final int TOUCH_STATE_REST = 0;
// 滑动状态
private static final int TOUCH_STATE_SCROLLING = 1;
// 表示当前状态
private int mTouchState = TOUCH_STATE_REST;
// 上一次事件的位置
private float mLastMotionX;
// 触发滚动的最小滑动距离,手指滑动超过该距离才认为是要拖动,防止手抖
private int mTouchSlop = ViewConfiguration.get(getContext()).getScaledTouchSlop();
// 最小滑动速率,手指滑动超过该速度时才会触发翻页
private static final int VELOCITY_MIN = 600;
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
final int action = ev.getAction();
//表示已经开始滑动了,不需要走该 ACTION_MOVE 方法了。
if ((action == MotionEvent.ACTION_MOVE) && (mTouchState != TOUCH_STATE_REST)) {
return true;
}
final float x = ev.getX();
switch (action) {
case MotionEvent.ACTION_DOWN:
mLastMotionX = x;
mTouchState = mScroller.isFinished() ? TOUCH_STATE_REST : TOUCH_STATE_SCROLLING;
break;
case MotionEvent.ACTION_MOVE:
final int xDiff = (int) Math.abs(mLastMotionX - x);
//超过了最小滑动距离,就可以认为开始滑动了
if (xDiff > mTouchSlop) {
mTouchState = TOUCH_STATE_SCROLLING;
}
break;
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:
mTouchState = TOUCH_STATE_REST;
break;
}
return mTouchState != TOUCH_STATE_REST;
}
@Override
public boolean onTouchEvent(MotionEvent event) {
super.onTouchEvent(event);
// 速度监控器,监控每一个 event
if (mVelocityTracker == null) {
mVelocityTracker = VelocityTracker.obtain();
}
mVelocityTracker.addMovement(event);
// 触摸点
final float eventX = event.getX();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
// 如果滚动未结束时按下,则停止滚动
if (!mScroller.isFinished()) {
mScroller.abortAnimation();
}
// 记录按下位置
mLastMotionX = eventX;
break;
case MotionEvent.ACTION_MOVE:
// 手指移动的位移
int deltaX = (int)(eventX - mLastMotionX);
// 滚动内容,前提是不超出边界
int targetScrollX = getScrollX() - deltaX;
if (targetScrollX >= 0 &&
targetScrollX <= getWidth() * (CHILD_NUMBER - 1)) {
scrollTo(targetScrollX, 0);
}
// 记下手指的新位置
mLastMotionX = eventX;
break;
case MotionEvent.ACTION_UP:
// 计算速度
mVelocityTracker.computeCurrentVelocity(1000);
float velocityX = mVelocityTracker.getXVelocity();
if (velocityX > VELOCITY_MIN && canMoveToIndex(getCurrentIndex() - 1)) {
// 自动向右边继续滑动
moveToIndex(getCurrentIndex() - 1);
} else if (velocityX < -VELOCITY_MIN && canMoveToIndex(getCurrentIndex() + 1)) {
// 自动向左边继续滑动
moveToIndex(getCurrentIndex() + 1);
} else {
// 手指速度不够或不允许再滑
int targetIndex = (getScrollX() + getWidth() / 2) / getWidth();
moveToIndex(targetIndex);
}
// 回收速度监控器
if (mVelocityTracker != null) {
mVelocityTracker.recycle();
mVelocityTracker = null;
}
//修正 mTouchState 值
mTouchState = TOUCH_STATE_REST;
break;
case MotionEvent.ACTION_CANCEL:
mTouchState = TOUCH_STATE_REST;
break;
}
return true;
}
}

在该例子中,在 View 的 onTouchEvent 方法中,在 ACTION_MOVE 手指移动中不断调用 scrollTo 方法,实现 View 跟随手指移动;同时,将 Event 不断地添加到 mVelocityTracker 速度监控器中,并在 ACTION_UP 手指抬起时从速度监控器中获取速度,当速度达到某一阈值时自动滚动到上一页或下一页。

总结

至此,我们已经了解了 View 的滚动原理,并两个辅助类来帮助控制 View 的滚动位置和滚动速度。总结一下:

  • View 的显示可以理解为透过“视觉窗口”来看内容,内容可以无限大,改变 View 的 mScrollXmScrollY 可以看到不同的内容,实现瞬时滚动。
  • 调用 View 的 scrollToscrollBy 方法可以瞬时滚动 View。
  • Scroller 辅助类可以协助实现 View 的滚动动画,实现方法是:调用 startScroll 方法开始滚动,并在 View 的 computeScroll 方法中不断改变 mScrollXmScrollY 来滚动 View。
  • VelocityTracker 辅助类可以协助追踪 View 的滚动速度,通常是在 View 的 onTouchEvent 方法中将 Event 传进该类中来追踪。调用该类的 computeCurrentVelocity 方法之后,就可以调用 getXVelocitygetYVelocity 方法分别获取 x 方向和 y 方向的速度。

有了上述的知识和工具后,我们就能实现很多与滚动相关的效果,例如本文开头提到的几个场景,后续再写些 DEMO 作为分享。

以上,感谢阅读。