初识Scroller

Scroller

Scroller是一个专门用于处理滚动效果的工具类,例如ViewPager,ListView等控件都是通过Scroller来实现的。那么来学习下Scroller的用法。

知识点

在学习Scroller之前我们有以下几个知识点需要了解:

  • 任何一个控件都可以实现滚动,借助View.java中的ScrollTo()和scrollBy()方法来实现。

查看文档,两者的介绍中都有一句话,视图将会废除。也就是说调用这两个方法,视图将会重绘。这就联想到了之前在学习View绘制的draw过程,ViewGroup会通过drawChild方法为子View分配合适的canvas,大小是由layout过程决定,而位置则由ViewGroup滚动值,及其当前子View的动画决定。

那么两个方法有什么区别吗?scrollBy()方法是让View相对于当前的位置滚动某段距离,而scrollTo()方法则是让View相对于初始的位置滚动某段距离。
实践效果图:

TestScrollMethod.gif

从上图可以看到,当我们点击一次scrollTo按钮后,两个按钮同时向下移动(传入的参数x=-60,y=-100)。再次点击scrollTo按钮,无反应。当我们不断点击scrollBy按钮时,两个按钮不断的向下移动。代码实现如下:

/**
 * Created by syt on 16/8/23.
 */
public class TestScrollToByActivity extends AppCompatActivity{
    private LinearLayout mLayout;
    private Button mScrollTo;
    private Button mScrollBy;
    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_scroll_to_by);

        mLayout = (LinearLayout) findViewById(R.id.test_scroll_layout);
        mScrollBy = (Button) findViewById(R.id.scroll_by);
        mScrollTo = (Button) findViewById(R.id.scroll_to);

        mScrollTo.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                mLayout.scrollTo(-60, -100);
            }
        });

        mScrollBy.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                mLayout.scrollBy(-60, -100);
            }
        });
    }
}

代码中只是简单的给两个按钮注册了点击监听器,并实现了onClick方法。在这个方法里面分别调用了layout的scrollTo和scrollBy方法。这里有两点需要注意,我们调用哪个View的scrollTo或者scrollBy方法,滚动的就是这个view的内容;这两个方法传入的参数意义相同:x传入正值向左移动,传入负值向右移动;y传入正值向上移动,传入负值向下移动,单位都是像素。

  • 要实现滑动,需要了解视图的坐标,以及得到视图坐标的方法:
    • View提供的获取坐标方法
      getTop():获取到的是View自身的顶边到其父布局顶边的距离
      getLeft():获取到的是View自身的左边到其父布局左边的距离
      getRight():获取到的是View自身的右边到其父布局左边的距离
      getBottom():获取到的是View自身的底边到其父布局顶边的距离
    • MotionEvent提供的方法
      getX():获取点击事件距离控件左边的距离,即视图坐标
      getY():获取点击事件距离空间顶边的距离,即视图坐标
      getRawX():获取点击事件距离整个屏幕左边的距离,即绝对坐标
      getRawY():获取点击事件距离整个屏幕顶边的距离,即绝对坐标
    • getScrollX()和getScrollY()方法
      这两个方法类似,这里就解释getScrollX()方法。getScrollX()方法就是获取视图绘制区域在窗口左边界的值。而原点(0,0)就是初始化时,窗口左边界在绘制区域的位置。原点往左是负值,往右是正值。

这里需要注意的是,getX()方法获取的坐标,是相对于消费事件的View坐标系。也就是说同一个事件触发点,根据不同的消费该事件的View,用该方法,得到的坐标值是不同的。实验如下:
首先我们自定义个layout继承自LinearLayout,在这个ViewGroup中,通过修改onTnterceptTouchEvent()方法来拦截事件,实现针对同一事件,有不同的消费事件View。代码如下:

public class MyLayout extends LinearLayout{
    public MyLayout(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        return true;
        //return super.onInterceptTouchEvent(ev);
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch(event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                Log.d("My layout", "get X is " + event.getX());
        }
        return super.onTouchEvent(event);
    }
}

同时我们在activity中实现自定义ViewGroup中的子View的滑动监听:

public class TestGetMethodActivity extends AppCompatActivity{
    private Button mTestGetMed;
    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_test_get_x);
        mTestGetMed = (Button) findViewById(R.id.test_get_med);
        mTestGetMed.setOnTouchListener(new View.OnTouchListener() {
            @Override
            public boolean onTouch(View v, MotionEvent event) {
                switch(event.getAction()) {
                    case MotionEvent.ACTION_MOVE:
                        Log.d("Button", "get X is " + event.getX());
                        break;
                }
                return false;
            }
        });
    }
}

这样一来,通过事件拦截,既可以让Button(子View)来处理事件,也可以直接让自定义的Layout来处理事件。这里的处理都是打印出getX()返回的值。日志打印如下:
子View处理事件:

子view处理事件.png

layout处理事件:

layout处理事件.png

根据上述打日志,当子View消费事件时,无论怎么点击,最大不能超过子View的宽度(我在XML布局中定义了最宽度为200px);当消费事件变成自定义的ViewGroup时,点按子View得到的值变大了很多,并且超过了子View的宽度。由此可以验证之前的结论,getX()方法针对不同的消费事件View,处理同一个事件,返回不同的坐标值。

Scroller用法

通过View.java自带的scrollTo()和scrollBy()方法就可以实现滚动,但是其效果不太友好,体验效果差。所以我们需要借助Scroller来实现平滑的滚动。
Scroller的用法比较简单。

  1. 创建Scroller类
  2. 调用startScroll()方法来初始化滚动数据并刷新界面
  3. 重写computeScroll()方法,并在其内部完成平滑滚动的逻辑

那么直接来实践吧!

/**
 * Created by syt on 16/8/23.
 */
public class ScrollLayout extends ViewGroup{
    /**
     *滚动操作实例
     */
    private Scroller mScroller;
    /**
     *判定为移动的最小像素数
     */
    private int mTouchSlop;
    /**
     * 手按下时的坐标X
     */
    private float mXDown;
    /**
     * 手当时所出的坐标X
     */
    private float mXMove;
    /**
     * 上次触发Action_Move事件时,手所处的坐标X;
     */
    private float mLastMove;
    /**
     * 屏幕可滚动的左边界
     */
    private int leftBorder;
    /**
     * 屏幕可滚动的右边界
     */
    private int rightBorder;
    /**
     * 子View个数
     */
    private int childCount;

    private static final String TAG = "ScrollLayout--->";

    public ScrollLayout(Context context, AttributeSet attrs) {
        super(context, attrs);
        mScroller = new Scroller(context);
        ViewConfiguration configuration = ViewConfiguration.get(context);
        mTouchSlop = configuration.getScaledTouchSlop();//获取Touch Slop的值
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        childCount = getChildCount();
        for(int i = 0; i < childCount; i++) {
            View child = getChildAt(i);
            //由于这里实现的是Scroll Layout,调用默认的计算宽,高的方法就可以
            measureChild(child, widthMeasureSpec, heightMeasureSpec);
        }
    }

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        if (changed) {
            for(int i = 0; i < childCount; i++) {
                View child = getChildAt(i);
                child.layout(i * child.getMeasuredWidth(), 0, (i + 1) * child.getMeasuredWidth(),
                        child.getMeasuredHeight());
            }
        }
        leftBorder = getChildAt(0).getLeft();
        Log.d(TAG, "left border is " + leftBorder);
        rightBorder = getChildAt(childCount - 1).getRight();
    }

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        switch(ev.getAction()) {
            case MotionEvent.ACTION_DOWN:
                mXDown = ev.getRawX();
                mLastMove = mXDown;
                break;
            case MotionEvent.ACTION_MOVE:
                mXMove = ev.getRawX();
                Log.d(TAG, "onInterceptTouchEvent move is " + mXMove);
                float diff = Math.abs(mXMove - mXDown);
                mLastMove = mXMove;
                Log.d(TAG, "onInterceptTouchEvent last move is " + mLastMove);
                //当拖动的值大于touchSlop时,认为应该进行滑动,拦截事件,不传递到子View
                if(diff > mTouchSlop) {
                    return true;
                }
                break;
        }
        return super.onInterceptTouchEvent(ev);
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch(event.getAction()) {
            case MotionEvent.ACTION_MOVE:
                mXMove = event.getRawX();
                Log.d(TAG, "onTouchEvent move is " + mXMove);
                Log.d(TAG, "onTouchEvent last move is " + mLastMove);
                int scrolledX = (int) (mLastMove - mXMove);
                Log.d(TAG, "onTouchEvent scrolled x is " + scrolledX);
                if(getScrollX() + scrolledX < leftBorder) {
                    scrollTo(leftBorder, 0);
                    return true;
                }else if(getScrollX() + scrolledX + getWidth() > rightBorder) {
                    scrollTo(rightBorder - getWidth(), 0);
                    return true;
                }
                scrollBy(scrolledX, 0);
                mLastMove = mXMove;
                break;
            case MotionEvent.ACTION_UP:
                //当手指抬起时,根据当前滑动到的位置,来判定应该滚动到哪个子View
                //当滚动的位置超过子View宽的一半,那么认为要滚动到该View
                int targetIndex = (getScrollX() + getWidth() / 2) / getWidth();
                int dx = targetIndex * getWidth() - getScrollX();
                //调用startScroll方法来实现后续的滚动,即滚动到判定之后得到的子View.
                mScroller.startScroll(getScrollX(), 0, dx, 0);//初始化滚动数据
                invalidate();//刷新界面,通过ScrollLayout的父View来刷新ScrollLayout,
                             // 从而调用computeScroll方法.
                break;
        }
        return super.onTouchEvent(event);
    }

    @Override
    public void computeScroll() {
        if (mScroller.computeScrollOffset()) {
            scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
            invalidate();
        }
    }
}

运行效果图如下:

TestScoller.gif

代码解释

  1. 当我们事件判定为滑动时,在onInterceptTouchEvent()方法中判断滑动的距离是否满足Android系统定义的滑动尺度。如果满足,就将事件拦截,交给自定义的ScrollLayout处理,否则不拦截。
  2. 当拦截事件后,事件传递给onTouchEvent()方法。此时通过event.getRawX()方法获取到一个新的滑动坐标,并且计算距上一次得到的坐标的距离。然后调用ScrollLayout的scrollBy()方法将视图跟着手指的移动而滚动。
  3. 为了防止用户拖出边界,做了一定的判断。根据当前的坐标,加上滑动的距离计算出最终滑动到的位置,再结合边界的坐标来判断是否响应滑动,如果超出边界,就调用ScrollLayout的scrollTo()方法保持内容处于边界的状态。
  4. 当事件为ACTION_UP时,说明用户手指不在滑动。但是目前很有可能用户只是将布局拖动到了中间,我们不可能让布局就这么停留在中间的位置,因此接下来就需要借助Scroller来完成后续的滚动操作。首先这里我们先根据当前的滚动位置来计算布局应该继续滚动到哪一个子控件的页面,然后计算出距离该页面还需滚动多少距离。接下来我们就该进行上述步骤中的第二步操作,调用startScroll()方法来初始化滚动数据并刷新界面。startScroll()方法接收四个参数,第一个参数是滚动开始时X的坐标,第二个参数是滚动开始时Y的坐标,第三个参数是横向滚动的距离,正值表示向左滚动,第四个参数是纵向滚动的距离,正值表示向上滚动。紧接着调用invalidate()方法来刷新界面。
  5. 重写computeScroll()方法,并在其内部完成平滑滚动的逻辑 。在整个后续的平滑滚动过程中(也就是当用户完成滑动,ScrollLayout调用startScroll方法刷新界面的操作),computeScroll()方法是会一直被调用的,因此我们需要不断调用Scroller的computeScrollOffset()方法来进行判断滚动操作是否已经完成了,如果还没完成的话,那就继续调用scrollTo()方法,并把Scroller的curX和curY坐标传入,然后刷新界面从而完成平滑滚动的操作。这里为什么要去调用invalidate()方法就是为了让ViewGroup不断的重绘,因为之前调用了startScroll()方法来刷新界面,而这个方法刷新界面需要花费时间,所以要不断的通过computeScroll()来完成滚动的持续动作。

遇到的问题

onInterceptTouchEvent()方法的调用

拦截事件方法(onInterceptTouchEvent()方法),并不是每一次事件都会调用。在调用该方法之前会进行一个判断:if (actionMasked == MotionEvent.ACTION_DOWN || mFirstTouchTarget != null)
也只有当事件为DOWN或者mFirstTouchTarget不为空时才调用onInterceptTouchEvent()方法。如果DOWN事件由子View来消费了,那么此时mFirstTouchTarget不为空,ViewGroup判断是否拦截后续的事件,例如ACTION_MOVE等等。如果事件由ViewGroup自己消费,也就是拦截了事件,那么mFirstTouchTarget为空,之后的事件就不会再去调用onInterceptTouchEvent()方法,直接拦截事件。那么mFirstTouchTarget怎么赋值的呢?如果一个子View能够消耗事件,那么mFirstTouchTarget会指向子View,如果所有的子View都不能消耗事件,那么mFirstTouchTarget将为null。

startScroll()方法

查看文档,作用时指定时间内完成指定距离的滑动。该方法有两个重载方法,相同的参数有四个,代表开始坐标,以及滑动的X,Y坐标轴距离。第五个参数是指定时间。不指定时间的方法,默认为250毫秒。

computeScrollOffset()方法

当你想知道现在滚动到的坐标时,就调用该方法,返回值为boolean。如果返回true代表滚动动画未完成。之后调用Scroller.getCurrX()或者getCurrX()方法得到坐标。传入scrollTo()方法内,实现滚动。

computeScroll()方法调用

调用invalidate()方法后,ViewGroup就会被重绘。这里需要注意的是重绘一个View,是由其父View来刷新的。所以调用invalidate()方法后回去调用父View的draw()方法。在draw()方法中通过dispatchDraw()方法内的drawChild()来重新绘制View。在drawChild()方法内,会去调用View的computeScroll()方法,来实现滚动。

Scroller.abortAnimation()方法

停止动画效果,立即滚动到目标坐标

参考

郭神Scroller详解
getScrollX博客
computeScroll博客
invalidate博客
onInterceptTouchEvent方法调用博客
MotionEvent.getX不同的返回值博客1
MotionEvent.getX不同的返回值博客2

目标

  1. 理解invalidate()方法的调用
  2. 当View调用invalidate()方法重绘时,是从View树根开始刷新,还是让其父View来刷新
  3. invalidate(),postInvalidate(),postInvalidateDelayed()的区别

你可能感兴趣的:(初识Scroller)