Android之Scroller详解讲解-真正了解滚动处理

1.Scroller是什么?

Scroller本身不会去移动View,它是一个移动计算辅助类,用于跟踪控件滑动的轨迹,只相当于一个滚动轨迹的记录工具,最终还是通过View的scrollTo,scrollBy方法完成View的移动;

2.Scroller源码分析,如何实现滚动轨迹记录的呢?

再讲源码之前先了解两个重要的方法:

1)startScroll()

public void startScroll(int startX, int startY, int dx, int dy, int duration) {}

开始一个动画控制,由(startX,startY)在duration时间内前进(dx,dy)个单位,即达到偏移坐标为(startX+dx,startY+dy)

2)computeScrollOffset()

public boolean computeScrollOffset()

滑动过程中,根据当前已经消逝的时间计算当前偏移的坐标点,保存在mCurrX和mCurrY值中。

上面两个方法的源码如下:

public class Scroller  {

    private int mStartX; //水平方向,滑动时的起点偏移坐标
    private int mStartY; //垂直方向,滑动时的起点偏移坐标
    private int mFinalX; //滑动完成后的偏移坐标,水平方向
    private int mFinalY; //滑动完成后的偏移坐标,垂直方向

    private int mCurrX;  //滑动过程中,根据消耗时间计算出的当前的滑动偏移距离,水平方向
    private int mCurrY;  //滑动过程中,根据消耗时间计算出的当前的滑动偏移距离,垂直方向
    private int mDuration; //本次滑动的动画时间
    private float mDeltaX; //滑动过程中,在达到mFinalX前还需要滑动的距离,水平方向   
    private float mDeltaY; //滑动过程中,在达到mFinalX前还需要滑动的距离,垂直方向   

    public void startScroll(int startX, int startY, int dx, int dy) {
        startScroll(startX, startY, dx, dy, DEFAULT_DURATION);
    }
    
    /**
    *  开始一个动画控制,由(startX,startY)在duration时间内前进(dx,dy)个单位,即达到偏移坐标为(startX+dx,startY+dy)
    */
    public void startScroll(int startX, int startY, int dx, int dy, int duration) {
        mMode = SCROLL_MODE;
        mFinished = false;
        mDuration = duration;
        mStartTime = AnimationUtils.currentAnimationTimeMillis();
        mStartX = startX;
        mStartY = startY;
        mFinalX = startX + dx;    //确定本次滑动完成后的偏移坐标
        mFinalY = startY + dy;
        mDeltaX = dx;
        mDeltaY = dy;
        mDurationReciprocal = 1.0f / (float) mDuration;
    }
        
        /**
        *    滑动过程中,根据当前已经消耗的时间计算当前偏移的坐标点,保存在mCurrX和mCurrY值中
        */
        public boolean computeScrollOffset() {
        if (mFinished) {    //已经完成了本次动画控制,直接返回为false
            return false;
        }

        int timePassed = (int)(AnimationUtils.currentAnimationTimeMillis() - mStartTime);
    
        if (timePassed < mDuration) {
            switch (mMode) {
            case SCROLL_MODE:
                final float x = mInterpolator.getInterpolation(timePassed * mDurationReciprocal);
                mCurrX = mStartX + Math.round(x * mDeltaX);//计算出当前滑动的偏移位置,x轴
                mCurrY = mStartY + Math.round(x * mDeltaY);//计算出当前滑动的偏移位置,y轴
                break;
                ...
            }
        }
        else {
            mCurrX = mFinalX;
            mCurrY = mFinalY;
            mFinished = true;
        }
        return true;
    }
}

Scroller类中最重要的两个方法就是startScroll()和computeScrollOffset(),当时Scroller类只是一个滑动计算辅助类,它的startScroll()和computeScrollOffset()方法中也只是对一些轨迹参数进行设置和计算,真正需要进行滑动还的通过View的scrollTo(),scrollBy()方法。为此,View中提供了computeScroll方法来控制这个滑动流程。computeScroll()方法会在绘制子视图的时候进行调用。源码如下:

//由父视图调用用来请求子视图根据偏移值 mScrollX,mScrollY重新绘制
public void computeScroll(){} //空方法,自定义滑动功能的ViewGroup必须实现的方法体

因此Scroller类的基本使用流程可以总结如下:

(1)首先通过Scroller类的startScroll()开始一个滑动动画控制,里面进行了一些轨迹参数的设置和计算;

(2)在调用 startScroll()的后面调用invalidate();引起视图的重绘操作,从而触发ViewGroup中的computeScroll()被调用;

(3)在computeScroll()方法中,先调用Scroller类中的computeScrollOffset()方法,里面根据当前消耗时间进行轨迹坐标的计算,然后取得计算出的当前滑动的偏移坐标,调用View的scrollTo()方法进行滑动控制,最后也需要调用invalidate();进行重绘。 如下的一个简单代码示例:

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        int x = (int)event.getX();
        switch (event.getAction()){
            case MotionEvent.ACTION_DOWN:
                mLastX = x;
                break;
            case MotionEvent.ACTION_MOVE:
                int dx = mLastX - x;
                int oldScrollX = getScrollX();  //原来的偏移量
                int preScrollX = oldScrollX + dx; //本次滑动后的形成的偏移量
                if(preScrollX<0){
                    preScrollX = 0;
                    dx = preScrollX - oldScrollX;
                }
                if(preScrollX>(getChildCount()-1)*getWidth()){
                    preScrollX = (getChildCount()-1)*getWidth();
                    dx = preScrollX - oldScrollX;
                }
                mScroller.startScroll(mScroller.getFinalX(),mScroller.getFinalY(),dx,0,0);
                invalidate();
//                scrollTo(preScrollX, getScrollY());
                Log.d("MyViewPager", "oldScrollX:"+oldScrollX+" dx:"+dx);
                mLastX = x;
                break;
        }
        return true;
    }

    @Override
    public void computeScroll() {
        super.computeScroll();
        if(mScroller.computeScrollOffset()){
            scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
            Log.d("MyViewPager", "mScroller.getCurrX():"+mScroller.getCurrX());
            invalidate();
        }
    }

3.Scroller负责记录View的轨迹信息,View如何根据轨迹信息完成滚动操作、坐标体系规则是什么?

View通过scrollTo(),scrollBy()完成滚动操作;

scrollTo,scrollBy方法是View中的,因此任何View都可以通过这两个方法进行移动。首先要明白的是,scrollTo、scrollBy滑动的是View中的内容(而且还是整体滑动),而不是View本身。我们滑动控件如ScrollView可以限定宽,高大小,以及在布局中的位置,但是滑动控件中的内容(或者里面的childView)可以无限长、宽的,我们调用View的scrollTo,scrollBy方法,相当于是移动滑动控件中的画布canvas,然后进行重绘,屏幕上也可以显示相应的内容。如下:

 1).getScrollX()、getScrollY()

在学习scrollTo(),scrollBy()之前,先来了解一下getScrollX(),getScrollY()方法。

getScrollX(),getScrollY()得到的是偏移量,是相对自己初始位置的滑动偏移距离,只有当scroll事件发生时,这个两个方法才有值,否则getScrollX(),getScrollY()都是初始时的值为0,而不管你这个滑动控件在哪里。所谓自己初始位置是指,控件在刚开始显示时,没有滑动前的位置。以getScrollX()为例,其源码如下:

    public final int getScrollX() {
        return mScrollX;
    }

可以看到getScrollX()直接返回的是mScrollX,代表水平方向的偏移量,getScrollY也类似。偏移量mScroll的正负代表着滑动控件中的内容相对于 初始位置在水平方向上偏移的情况,mScrollX为正代表着当前内容相对于初始位置向左偏移了mScrollX的距离,mScrollX为负表示当前内容相对于初始位置向右偏移了mScrollX的距离。

这里的坐标系和我们平常的认知正好相反。为了以后更方便的处理滑动相应坐标和偏移,在处理偏移,滑动相关的功能时,我们可以把坐标反过来看,如下图:

 因为滑动控件中的内容是整体进行滑动的,同时也是相对于自己显示时的初始位置的偏移,对于View中的内容在偏移时的参考坐标原点(注意是内容视图的坐标原点,不是图中说的滑动控件的原点),可以选择初始位置的某一个地方,因为滑动时整体行为,在进行滑动时候从这个选择的原点出进行分析即可。

2).scrollTo(),scrollBy()

scrollTo(int x,int y)移动的是View中的内容,而滑动控件中的内容都是整体移动的,scrollTo(int x,int y)中的参数表示View中的内容相对于内容初始位置移动x和y的距离,即将内容移动到距离内容初始位置x和y的位置。正如前面所说,在处理偏移,滑动问题时坐标系和平常认知的坐标系是相反的,以一个例子说明scrollTo():

说明:图中黄色矩形区域表示的是一个可滑动的View控件,绿色虚线矩形为滑动控件中的滑动内容。注意这里的坐标是相反的。

a.调用scrollTo(100,0)表示将View中的内容移动到距离内容初始显示位置的x=100,y=0的地方,效果如下图:

b.调用scrollTo(0,100)效果如下图:

c.调用scrollTo(100,100)效果如下图:

 d.调用scrollTo(-100,0)效果如下图:

通过上面几个图,可以清楚看到scrollTo的作用和滑动坐标系的关系。在实际使用中,我们一般是在onTouchEvent()方法中处理滑动事件,在MotionEvent.ACTION_MOVE时调用scrollTo(int x,int y)进行滑动,在调用scrollTo(int x,int y)前,我们先要计算出两个参数值,即水平和垂直方向需要滑动的距离。如下:

 @Override
    public boolean onTouchEvent(MotionEvent event) {
        int y = (int)event.getY();
        int action = event.getAction();
        switch (action){
            case MotionEvent.ACTION_DOWN:
                mLastY = y;
                break;
            case MotionEvent.ACTION_MOVE:
                int dy = mLastY - y; //本次手势滑动了多大距离
                int oldScrollY = getScrollY(); //先计算之前已经偏离多少距离
                int scrollY = oldScrollY + dy; //本次需要偏移的距离=以前已经便宜的距离+本次手势滑动了多大距离
                if(scrollY <= 0){
                    scrollY = 0;
                }
                if(scrollY > getHeight() - mScreenHeight){
                    scrollY = getHeight() - mScreenHeight;
                }
                scrollTo(getScrollX(), scrollY);
                mLastY = y;
                break;
        }
        return true;
    }

上面再计算参数时,分了三步。第一是,通过int dy = mLastY-y;得到本次手势在屏幕上滑动了多少距离,这里特别注意这个相减的顺序,因为这里的坐标与平常是相反的,因此,手势滑动距离是按下时的坐标mLastY-当前的坐标y;第二是,通过oldScrollY=getScrollY();获得滑动内容之前已经距离初始位置偏移了多少;第三个是,计算本次需要偏移的参数int scrollY = oldScrollY+dy;后面通过两个if条件进行了边界处理,然后调用scrollTo进行滑动。调用完scrollTo后,新的偏移量又重新产生了。从scrollTo源码中可以看到:

public void scrollTo(int x, int y) {  
        if (mScrollX != x || mScrollY != y) {  
            int oldX = mScrollX;  
            int oldY = mScrollY;  
            mScrollX = x;//赋值新的x偏移量  
            mScrollY = y;//赋值新的y偏移量  
            invalidateParentCaches();  
            onScrollChanged(mScrollX, mScrollY, oldX, oldY);  
            if (!awakenScrollBars()) {  
                postInvalidateOnAnimation();  
            }  
        }  
    }  

scrollTo是相对于初始位置来进行滑动的,scrollBy(int x,int y)则是相对于上一次移动的距离来进行的本次移动;scrollBy其实还是依赖于scrollTo的,如下源码:

public void scrollBy(int x, int y) {  
        scrollTo(mScrollX + x, mScrollY + y);  
    }  

可以看到,使用scrollBy其实就是省略了我们在计算scrollTo参数时的第三部而已,因为scrollBy内部已经自己帮我加上了第三部的计算。因此scrollBy的作用就是相当于在上一次的偏移情况下进行本次偏移。

一个完整的水平方向滑动的例子:

public  class MyViewPager extends ViewGroup {
    
    //手指在屏幕水平方向X轴坐标
    private int mLastX;
    //Scroller滚动辅助类
    private Scroller mScroller;

    public MyViewPager(Context context) {
        this(context, null);
    }

    public MyViewPager(Context context, AttributeSet attrs) {
        this(context, attrs,0);
    }

    public MyViewPager(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init(context);
    }

    @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
    public MyViewPager(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
        super(context, attrs, defStyleAttr, defStyleRes);
        init(context);
    }

    private void init(Context context) {
        mScroller = new Scroller(context);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int count = getChildCount();
        for (int i = 0; i < count; i++) {
            View child = getChildAt(i);
            child.measure(widthMeasureSpec, heightMeasureSpec);
        }
    }

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        Log.d("TAG","--l-->"+l+",--t-->"+t+",-->r-->"+r+",--b-->"+b);
        int count = getChildCount();
        for (int i = 0; i < count; i++) {
            View child = getChildAt(i);
            //子视图水平方向布局显示
            child.layout(getWidth()*i,t,getWidth()*(i+1),b);
        }
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        int x = (int)event.getX();
        switch (event.getAction()){
            case MotionEvent.ACTION_DOWN:
                mLastX = x;
                break;
            case MotionEvent.ACTION_MOVE:
                //X轴偏移量
                int dx = mLastX - x;
                int oldScrollX = getScrollX();  //原来的偏移量
                int preScrollX = oldScrollX + dx; //本次滑动后的形成的偏移量(最终偏移位置)
                //越界检查-左侧,越界需要重新计算需要偏移的量dx
                if(preScrollX<0){
                    preScrollX = 0;
                    dx = preScrollX - oldScrollX;
                }
                //越界检查-右侧,越界需要重新计算需要偏移的量dx
                if(preScrollX>(getChildCount()-1)*getWidth()){
                    preScrollX = (getChildCount()-1)*getWidth();
                    dx = preScrollX - oldScrollX;
                }
                //调用mScroller的startScroll方法计算滚动轨迹
                mScroller.startScroll(mScroller.getFinalX(),mScroller.getFinalY(),dx,0,0);
                //必须要调用invalidate方法提示View内容显示位置变化
                invalidate();
                //去掉如下注释就不需要写如下三个方法(mScroller.startScroll()+invalidate()+computeScroll())
//              scrollTo(preScrollX, getScrollY());
                Log.d("MyViewPager", "oldScrollX:"+oldScrollX+" dx:"+dx);
                mLastX = x;
                break;
        }
        return true;
    }

    @Override
    public void computeScroll() {
        super.computeScroll();
        //true:表示滚动未结束,false:滚动完成
        if(mScroller.computeScrollOffset()){
            //滚动内容到当前位置X,Y坐标
            scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
            Log.d("MyViewPager", "mScroller.getCurrX():"+mScroller.getCurrX());
            invalidate();
        }
    }
}

布局文件:


        
        
        
        
    

效果如图:

Android之Scroller详解讲解-真正了解滚动处理_第1张图片

4.Scroller具体使用示例-自定义ViewGroup实现分页显示?

自定义ViewPager:

public class MyViewPager3 extends ViewGroup {

    private int mLastX;

    private Scroller mScroller;
    private VelocityTracker mVelocityTracker;
    private int mTouchSlop;
    private int mMaxVelocity;

    /**
     *  当前显示的第几个屏幕
     */
    private int mCurrentPage = 0;

    public MyViewPager3(Context context) {
        this(context, null);
    }

    public MyViewPager3(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public MyViewPager3(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init(context);
    }

    @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
    public MyViewPager3(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
        super(context, attrs, defStyleAttr, defStyleRes);
        init(context);
    }

    private void init(Context context) {
        mScroller = new Scroller(context);
        ViewConfiguration config = ViewConfiguration.get(context);
        mTouchSlop = config.getScaledPagingTouchSlop();
        mMaxVelocity = config.getScaledMinimumFlingVelocity();
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int count = getChildCount();
        for (int i = 0; i < count; i++) {
            View child = getChildAt(i);
            child.measure(widthMeasureSpec,heightMeasureSpec);
        }
    }

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        int count = getChildCount();
        Log.d("TAG","--l-->"+l+",--t-->"+t+",-->r-->"+r+",--b-->"+b);
        for (int i = 0; i < count; i++) {
            View child = getChildAt(i);
            child.layout(i*getWidth(),t,(i+1)*getWidth(),b);
        }
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        initVelocityTrackerIfNotExists();
        mVelocityTracker.addMovement(event);
        int x = (int)event.getX();
        switch (event.getAction()){
            case MotionEvent.ACTION_DOWN:
                if(!mScroller.isFinished()){
                    mScroller.abortAnimation();
                }
                mLastX = (int)event.getX();
                break;
            case MotionEvent.ACTION_MOVE:
                int dx = mLastX - x;
                int oldScrollX = getScrollX();
                int preScrollX = oldScrollX + dx;
                if(preScrollX>(getChildCount()-1)*getWidth()){
                    preScrollX = (getChildCount()-1)*getWidth();
                    dx = preScrollX - oldScrollX;
                }
                if(preScrollX < 0){
                    preScrollX = 0;
                    dx = preScrollX - oldScrollX;
                }
                mScroller.startScroll(mScroller.getFinalX(), mScroller.getFinalY(),
                        dx,0);
                //注意,使用startScroll后面一定要进行invalidate刷新界面,
                // 触发computeScroll()方法,因为单纯的startScroll()是属于Scroller的,
                // 只是一个辅助类,并不会触发界面的绘制
                invalidate();
//                scrollBy(dx,0);
                mLastX = x;
                break;
            case MotionEvent.ACTION_UP:
                final VelocityTracker velocityTracker = mVelocityTracker;
                velocityTracker.computeCurrentVelocity(1000);
                int initVelocity = (int)velocityTracker.getXVelocity();
                //如果是快速的向右滑,则需要显示上一个屏幕
                if(initVelocity>mMaxVelocity && mCurrentPage>0){
                    Log.d("TAG","----------------快速的向右滑--------------------");
                    scrollToPage(mCurrentPage-1);
                }else if(initVelocity<-mMaxVelocity && mCurrentPage getChildCount() - 1){
            mCurrentPage = getChildCount() - 1;
        }
        //计算滑动到指定Page还需要滑动的距离
        int dx = mCurrentPage*getWidth() - getScrollX();
        //动画时间设置为Math.abs(dx) * 2 ms
        mScroller.startScroll(getScrollX(),0,dx,0);
        //记住,使用Scroller类需要手动invalidate
        invalidate();
    }

    @Override
    public void computeScroll() {
        Log.d("TAG", "---------computeScrollcomputeScrollcomputeScroll--------------");
        super.computeScroll();
        if(mScroller.computeScrollOffset()){
            scrollTo(mScroller.getCurrX(),mScroller.getCurrY());
            invalidate();
        }
    }

    private void recycleVelocityTracker(){
        if(mVelocityTracker != null){
            mVelocityTracker.recycle();
            mVelocityTracker = null;
        }
    }

    private void initVelocityTrackerIfNotExists(){
        if(mVelocityTracker == null){
            mVelocityTracker = VelocityTracker.obtain();
        }
    }

}

布局文件:


        
        
        
        
    

5.NestedScrollView实现原理分析?

待续...

6.简单总结

1)Scroller类能够帮助我们实现高级滑动功能,如手指抬起的惯性滑动功能。使用流程为,首先通过Scroller的startScroll()+invalidate()触发View的computeScroll,在computeScroll()中让Scroller类去计算最新的坐标信息,

拿到最新的坐标偏移信息后还是要调用View的scrollTo来实现滑动。可以看到,使用Scroller的整个流程比较简单,关键的是控制滑动的一些逻辑计算,比如上面例子中的计算什么时候该往哪一页滑动...

(2)Android后面推出了OverScroller类,OverScroller在整体功能上和Scroller类似,使用也相同。OverScroller类可以完全代替Scroller,相比Scroller,OverScroller主要是增加了对滑动到边界的一些控制,如增加一些回弹效果等,功能更加强大。

参考:

Android Scroller详解

高级UI<第四十四篇>:Android Scroller详解

图解Android View的scrollTo(),scrollBy(),getScrollX(), getScrollY()

你可能感兴趣的:(Android面试整理2021,Android拓展,Scroller,scrollTo,startScroll,computeScroll)