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

时间:2022-11-24 19:06:58

Chant 的博客

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

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

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

注:

  1. 本文没有尝试实现上述几种功能,只阐述基本原理和基础类的使用方法。
  2. 文中的例子只是截取了与 View 相关的代码,完整的示例代码请见DEMO
  3. 本文的源码分析基于 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 方法是滚动到特定位置,参数 xy 代表“绝对位置”,而 scrollBy 方法是在当前位置基础上滚动特定距离,参数 xy 代表“相对位置”。

另外,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 提供的方法设置 mScrollXmScrollY,来使 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 View 的 Touch 事件传递机制 Java 的自动装箱(autoboxing)与拆箱(unboxing) 
© 2016 - 2017  Chant由 Hexo 强力驱动  主题 - NexT.Mist