Chant 的博客
Android View 的滚动原理和 Scroller、VelocityTracker 类的使用
发表于 2016-08-31Android 开发中经常涉及 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 类中的源码:
12345678910111213141516171819 |
// View.javapublic 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 提供的两个方法来设置。
12 |
// 瞬时滚动到某个位置public void scrollTo(int x, int y) |
12 |
// 瞬时滚动某个距离public void scrollBy(int x, int y) |
看看两个方法的源码:
1234567891011121314151617181920212223242526272829303132 |
// 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:
12 |
// 获取 mScrollXpublic final int getScrollX() |
12 |
// 获取 mScrollYpublic final int getScrollY() |
看看源码中这两个方法的注释,可以更好地理解 scroll 的概念。
12345678910111213141516171819202122 |
// 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
,也为后续介绍的知识做准备,我们先看一个例子:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103 |
/*** 示例:自定义 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 提供了一系列方法来执行滚动、计算滚动位置,以下列出几个重要方法:
12 |
// 开始滚动,并记下当前时间点作为开始滚动的时间点public void startScroll(int startX, int startY, int dx, int dy, int duration) |
12 |
// 停止滚动public void abortAnimation() |
12 |
// 计算当前时间点对应的滚动位置,并返回动画是否还在进行public boolean computeScrollOffset() |
12 |
// 获取上一次 computeScrollOffset 执行时的滚动 x 值public final int getCurrX() |
12 |
// 获取上一次 computeScrollOffset 执行时的滚动 y 值public final int getCurrY() |
12 |
// 根据当前的时间点,判断动画是否已结束public final boolean isFinished() |
有了这几个方法,我们容易想到如何实现 View 的平滑滚动动画:
- 在开始动画时调用
startScroll
方法,传入动画开始位置、移动距离、动画时长; - 每隔一段时间,调用
computeScrollOffset
方法,计算当前时间点对应的滚动位置; - 如果上一步返回 true,代表动画仍在进行,则调用
getCurrX
和getCurrY
方法获取当前位置,并调用 View 的scrollTo
方法使 View 滚动; - 不断循环进行第 2 步,直到返回 false,代表动画结束。
这里提到“每隔一段时间”,从直觉上我们可能觉得应该有个循环,但实际上我们可以借助 View 的 computeScroll
方法来实现。先看看 computeScroll
方法的源码:
123456789 |
// 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 来滚动,实现了滚动的动画,而不再是瞬时滚动。
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172 |
/*** 示例:自定义一个 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 类提供的几个常用的方法,这些方法分为几类:
-
初始化和销毁:
12
// 由系统分配一个 VelocityTracker 对象,而不是 new 一个static public VelocityTracker obtain()
12
- // 使用完毕时调用该方法回收 VelocityTracker 对象public void recycle()
-
添加 Event 以供追踪:
12
// 不断调用该方法传入一系列 event,记录用户的操作public void addMovement(MotionEvent event)
-
计算速度:
12
// 计算调用该方法的时刻对应的速度,传入的是速度的计时单位public void computeCurrentVelocity(int units)
12
// 调用 computeCurrentVelocity 方法后就可以通过该方法获取之前计算的 x 方向速度public float getXVelocity()
12
// 调用 computeCurrentVelocity 方法后就可以通过该方法获取之前计算的 y 方向速度public float getYVelocity()
例子3
下面通过一个例子来看看 VelocityTracker 的用法。该例子的 ViewGroup 继承自例子 2 的 ViewGroup,拥有同样的子 View,区别在于除了可以用动画来滚动,还可以用手势来拖动滚动。重点看该 ViewGroup 的 onTouchEvent 方法:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137 |
/*** 示例:自定义一个 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 方向的速度。
有了上述的知识和工具后,我们就能实现很多与滚动相关的效果,例如本文开头提到的几个场景,后续再写些 DEMO 作为分享。
以上,感谢阅读。
#Android #UI Android View 的 Touch 事件传递机制 Java 的自动装箱(autoboxing)与拆箱(unboxing)- 文章目录
- 站点概览