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 类中的源码:

// 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 提供的两个方法来设置。

// 瞬时滚动到某个位置
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) 方法,它除了设置 mScrollXmScrollY 两个变量,还会触发自己重新绘制,另外还会通过 onScrollChanged 触发回调。而 scrollBy 方法其实也是调用 scrollTo 方法。

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

另外,View 还提供了 mScrollXmScrollY 的 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

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

/**
* 示例:自定义 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 提供了一系列方法来执行滚动、计算滚动位置,以下列出几个重要方法:

// 开始滚动,并记下当前时间点作为开始滚动的时间点
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,代表动画仍在进行,则调用 getCurrXgetCurrY 方法获取当前位置,并调用 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 的 mScrollXmScrollY 值,该方法会在父 View 调用该 View 的 draw 方法之前被自动调用,View 类中默认没有实现任何内容,我们需要自己实现。所以我们只需要在该方法中,用 Scroller 计算并设置 mScrollXmScrollY 的值,并判断如果动画没结束则让该 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 的 mScrollXmScrollY 可以看到不同的内容,实现瞬时滚动。
  • 调用 View 的 scrollToscrollBy 方法可以瞬时滚动 View。
  • Scroller 辅助类可以协助实现 View 的滚动动画,实现方法是:调用 startScroll 方法开始滚动,并在 View 的 computeScroll 方法中不断改变 mScrollXmScrollY 来滚动 View。
  • VelocityTracker 辅助类可以协助追踪 View 的滚动速度,通常是在 View 的 onTouchEvent 方法中将 Event 传进该类中来追踪。调用该类的 computeCurrentVelocity 方法之后,就可以调用 getXVelocitygetYVelocity 方法分别获取 x 方向和 y 方向的速度。

有了上述的知识和工具后,我们就能实现很多与滚动相关的效果。

以上,感谢阅读。

你可能感兴趣的:(Android View 的滚动原理和 Scroller、VelocityTracker 类的使用)