Android 开发中经常涉及 View 的滚动,例如类似于 ScrollView 的滚动手势和滚动动画,例如用 ListView 模仿 iOS 上的左滑删除 item,例如 ListView 的下拉刷新。这些都是常见的需求,同时也都涉及 View 滚动的相关知识。
本文将解析 Android 中 View 的滚动原理,并介绍与滚动相关的两个辅助类 Scroller
和 VelocityTracker
,并通过 3 个逐渐深入的例子来加深理解。
注:
- 本文没有尝试实现上述几种功能,只阐述基本原理和基础类的使用方法。
- 文中的例子只是截取了与 View 相关的代码,完整的示例代码请见DEMO
- 本文的源码分析基于 Android API Level 21,并省略掉部分与本文关系不大的代码。
View 的滚动原理
在了解 View 的滚动原理之前,我们先来想象一个场景:我们坐在一个房间里,透过一扇窗户看窗外的风景。窗户是有大小限制的,而风景是没有大小限制的。
把上述的场景对应到 Android 的 View 显示原理上来:当一个 View 显示在界面上,它的上下左右边缘就围成了这个 View 的可视区域,我们可以称这个区域为“可视窗口”,我们平时看到的 View 的内容,都是透过这个可视窗口中看到的“风景”。View 的大小内容可以无穷大,不受可视窗口大小的限制。
另外,如果在窗外的风景中,有一个人出现在窗户右边很远的地方,那么我们在房间里就看不到那个人;如果那个人站在窗户正对着出去的地方,那么我们就可以透过窗户看到他。对应到 View 上面来,只有出现在“可视窗口”中的那部分内容可以被看到。
View 的 scroll 相关
在 View 类中,有两个变量 mScrollX
和 mScrollY
,它们记录的是 View 的内容的偏移值。mScrollX
和 mScrollY
的默认值都是 0,即默认不偏移。另外我们需要知道一点,向左滑动,mScrollX
为正数,反正为负数。假设我们令 mScrollX = 10
,那么该 View 的内容会相对于原来向左偏移 10px。 看看系统的 View 类中的源码:
// 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;
// ...
}
通常我们比较少直接设置 mScrollX
和 mScrollY
,而是通过 View 提供的两个方法来设置。
// 瞬时滚动到某个位置
public void scrollTo(int x, int y)
// 瞬时滚动某个距离
public void scrollBy(int x, int y)
看看两个方法的源码:
// 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)
方法,它除了设置 mScrollX
和 mScrollY
两个变量,还会触发自己重新绘制,另外还会通过 onScrollChanged
触发回调。而 scrollBy
方法其实也是调用 scrollTo
方法。
明显,两个方法的区别在于 scrollTo
方法是滚动到特定位置,参数 x
、y
代表“绝对位置”,而 scrollBy
方法是在当前位置基础上滚动特定距离,参数 x
、y
代表“相对位置”。
另外,View 还提供了 mScrollX
和 mScrollY
的 getter:
// 获取 mScrollX
public final int getScrollX()
// 获取 mScrollY
public final int getScrollY()
看看源码中这两个方法的注释,可以更好地理解 scroll 的概念。
// 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
为了更好地理解 mScrollX
和 mScrollY
,也为后续介绍的知识做准备,我们先看一个例子:
/**
* 示例:自定义 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 提供的方法设置 mScrollX
、mScrollY
,来使 View “滚动”。但这种滚动都是瞬时的,换句话说,这种滚动都是无动画的。实际上我们想要做到的滚动是平滑的、有动画的,就像我们不希望窗户外面的那个人突然出现在窗户中间,这样会吓到我们,我们更希望那个人能有一个“慢慢走进视觉范围”的过程。
Scroller 类就是帮助我们实现 View 平滑滚动的一个辅助类,使用方法通常是在 View 中作为一个成员变量,用 Scroller 类来记录/计算 View 的滚动位置,再从 Scroller 类中读取出计算结果,设置到 View 中。这里注意一点:在 Scroller 中设置和计算 View 的滚动位置并不会影响 View 的滚动,只有从 Scroller 中取出计算结果并设置到 View 中时,滚动才会实际生效。
Scroller 提供了一系列方法来执行滚动、计算滚动位置,以下列出几个重要方法:
// 开始滚动,并记下当前时间点作为开始滚动的时间点
public void startScroll(int startX, int startY, int dx, int dy, int duration)
// 停止滚动
public void abortAnimation()
// 计算当前时间点对应的滚动位置,并返回动画是否还在进行
public boolean computeScrollOffset()
// 获取上一次 computeScrollOffset 执行时的滚动 x 值
public final int getCurrX()
// 获取上一次 computeScrollOffset 执行时的滚动 y 值
public final int getCurrY()
// 根据当前的时间点,判断动画是否已结束
public final boolean isFinished()
有了这几个方法,我们容易想到如何实现 View 的平滑滚动动画:
- 在开始动画时调用
startScroll
方法,传入动画开始位置、移动距离、动画时长; - 每隔一段时间,调用
computeScrollOffset
方法,计算当前时间点对应的滚动位置; - 如果上一步返回 true,代表动画仍在进行,则调用
getCurrX
和getCurrY
方法获取当前位置,并调用 View 的scrollTo
方法使 View 滚动; - 不断循环进行第 2 步,直到返回 false,代表动画结束。
这里提到“每隔一段时间”,从直觉上我们可能觉得应该有个循环,但实际上我们可以借助 View 的 computeScroll
方法来实现。先看看 computeScroll
方法的源码:
// 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 的 mScrollX
和 mScrollY
值,该方法会在父 View 调用该 View 的 draw 方法之前被自动调用,View 类中默认没有实现任何内容,我们需要自己实现。所以我们只需要在该方法中,用 Scroller 计算并设置 mScrollX
和 mScrollY
的值,并判断如果动画没结束则让该 View 失效(调用 postInvalidate()
方法),触发下一次 computeScroll
,就可以实现上述循环。
例子2
这个例子的 ViewGroup 继承自例子 1 的 ViewGroup,拥有同样的子 View,区别只在于例子 2 是通过 Scroller 来滚动,实现了滚动的动画,而不再是瞬时滚动。
/**
* 示例:自定义一个 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 类提供的几个常用的方法,这些方法分为几类:
-
初始化和销毁:
// 由系统分配一个 VelocityTracker 对象,而不是 new 一个 static public VelocityTracker obtain()
- // 使用完毕时调用该方法回收 VelocityTracker 对象 public void recycle()
-
添加 Event 以供追踪:
// 不断调用该方法传入一系列 event,记录用户的操作 public void addMovement(MotionEvent event)
-
计算速度:
// 计算调用该方法的时刻对应的速度,传入的是速度的计时单位 public void computeCurrentVelocity(int units)
// 调用 computeCurrentVelocity 方法后就可以通过该方法获取之前计算的 x 方向速度 public float getXVelocity()
// 调用 computeCurrentVelocity 方法后就可以通过该方法获取之前计算的 y 方向速度 public float getYVelocity()
例子3
下面通过一个例子来看看 VelocityTracker 的用法。该例子的 ViewGroup 继承自例子 2 的 ViewGroup,拥有同样的子 View,区别在于除了可以用动画来滚动,还可以用手势来拖动滚动。重点看该 ViewGroup 的 onTouchEvent 方法:
/**
* 示例:自定义一个 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 的
mScrollX
和mScrollY
可以看到不同的内容,实现瞬时滚动。 - 调用 View 的
scrollTo
或scrollBy
方法可以瞬时滚动 View。 - Scroller 辅助类可以协助实现 View 的滚动动画,实现方法是:调用
startScroll
方法开始滚动,并在 View 的computeScroll
方法中不断改变mScrollX
和mScrollY
来滚动 View。 - VelocityTracker 辅助类可以协助追踪 View 的滚动速度,通常是在 View 的
onTouchEvent
方法中将 Event 传进该类中来追踪。调用该类的computeCurrentVelocity
方法之后,就可以调用getXVelocity
和getYVelocity
方法分别获取 x 方向和 y 方向的速度。
有了上述的知识和工具后,我们就能实现很多与滚动相关的效果。
以上,感谢阅读。