站在源码的肩膀上全解Scroller工作机制

Posted 谷哥的小弟

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了站在源码的肩膀上全解Scroller工作机制相关的知识,希望对你有一定的参考价值。


探索Android软键盘的疑难杂症
深入探讨Android异步精髓Handler
详解Android主流框架不可或缺的基石
站在源码的肩膀上全解Scroller工作机制


Android多分辨率适配框架(1)— 核心基础
Android多分辨率适配框架(2)— 原理剖析
Android多分辨率适配框架(3)— 使用指南


自定义View系列教程00–推翻自己和过往,重学自定义View
自定义View系列教程01–常用工具介绍
自定义View系列教程02–onMeasure源码详尽分析
自定义View系列教程03–onLayout源码详尽分析
自定义View系列教程04–Draw源码分析及其实践
自定义View系列教程05–示例分析
自定义View系列教程06–详解View的Touch事件处理
自定义View系列教程07–详解ViewGroup分发Touch事件
自定义View系列教程08–滑动冲突的产生及其处理


PS:如果觉得文章太长,你也可观看该课程的视频教程,亲,里面还有高清,无码的福利喔


前言

android开发中有多种方式实现View的滑动,常见的有三种如下:

  1. 不断地修改View的LayoutParams
  2. 采用动画向View施加位移效果
  3. 调用View的scrollTo( )、scrollBy( )

前两种方式我们还是挺熟悉的,不但见得挺多的而且还经常使用;至于最后一种方式,可能就要相对陌生些了。
其实,在Android中我们常见到的ListView、Launcher、SlidingMenu、ViewPager等等这些具有弹性滑动的View的背后都隐藏着一个机智又乖巧的小精灵——Scroller。这些控件的使用场景和作用各不相同,但在它们的内部均广泛又深刻地使用了Scroller的scrollTo()和scrollBy(),如此的实现不但丰富了操作方式而且极大提升了用户体验。

在此,我们从源码到实例,由简单到复杂,从表象到机制,一步步走进既陌生却又有点熟悉的Scroller


scrollTo( )和scrollBy( )

在View的源码中,系统提供了scrollTo()和scrollBy()这两个方法用于实现View的滚动。这两个方法又有什么联系呢,我们先来瞅瞅scrollTo()的源码:

/**
  * Set the scrolled position of your view. 
  * This will cause a call to onScrollChanged() 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();
        }
    }
}

scrollTo()是实现View滚动的核心,调用该方法使得View相对于其初始位置滚动某段距离。在该方法内部将输入参数x,y分别赋值给用于表示View在X方向滚动距离的mScrollX和表示View在Y方向滚动距离的mScrollY,然后调用onScrollChanged()并且刷新重绘View。在后续的操作中调用view.getScrollX()或view.getScrollY()可以很容易地得到mScrollX和mScrollY,关于这两个值我们再看看源码是怎么说的。

关于mScrollX,官方文档描述如下:

/**
 * The offset, in pixels, by which the content of this view is scrolled horizontally.
 */
protected int mScrollX;

关于mScrollY,官方文档描述如下:

/**
 * The offset, in pixels, by which the content of this view is scrolled vertically.
 */
protected int mScrollY;

mScrollX和mScrollY用于描述View的内容在水平方向或垂直方向滚动的距离。
什么是View的内容呢?比如,对于一个TextView而言,文本就是它的内容;对于一个ViewGroup而言,子View就是它的内容。
故在此,我们请务必注意:scrollTo()和scrollBy()滚动的是View的内容,而不是将View做整体的移动。

嗯哼,继续看scrollBy()的源码:

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

哇哈,看到了吧:scrollBy()的源码非常简洁,它仅仅是再次调用了scrollTo()。
直白地说:它只是把输入参数x,y累加到了mScrollX和mScrollY上而已。
所以,scrollBy()方法是在mScrollX和mScrollY的基础上滚动的。

小结:

  1. mScrollX和mScrollY分别表示View在X、Y方向的滚动距离
  2. scrollTo( )表示View相对于其初始位置滚动某段距离。
    由于View的初始位置是不变的,所以如果利用相同输入参数多次调用scrollTo()方法,View只会出现一次滚动的效果而不是多次。
  3. scrollBy( )表示在mScrollX和mScrollY的基础上继续滚动。

现在,已经对这两个方法有了基本的了解,我们再看看它们的用法。

这里写图片描述

在这个示例中对TextView分别调用scrollTo( )和scrollBy( ),代码如下:

private class ClickListenerImpl implements View.OnClickListener {
    @Override
    public void onClick(View v) {
        switch (v.getId()) {
            case R.id.firstButton:
                mTextView.scrollBy(30, 0);
                float scrollX=mTextView.getScrollX();
                float scrollY=mTextView.getScrollY();
                Log.i(TAG,"scrollX="+scrollX+",scrollY="+scrollY);
                break;
            case R.id.secondButton:
                mTextView.scrollTo(-300, 0);
                break;
            case R.id.zeroButton:
                mTextView.scrollTo(0,0);
                break;
            default:
                break;
        }
    }
}

当我们调用scrollBy()时,TextView的中的文本逐渐往其左侧滚动,当执行scrollTo()时TextView的中的文本会滚动到其右侧。嗯哼,在这是不是又印证了我们刚才的描述呢:执行scrollTo()和scrollBy()后View的内容发生了滚动,但是View本身是没有发生移动的。关于这点已经得到了验证,但是View的内容滚动的方向怎么和我们预想的不一样呢?平常我们不是说坐标是左负右正,上负下正么,为什么这里执行mTextView.scrollBy(30,0)时TextView的文本却是往X的负轴移动呢?

其实,许多人都是有类似的疑问,现在我们一起来探究其产生的原因。
在scrollTo()的源码中我们看到,该方法最后会调用postInvalidateOnAnimation()对View进行重绘从而执行到invalidate()。关于View的绘制以及Touch事件传递的更多详尽分析,请参见Android自定义View系列教程,此处不再赘述。在此以Android 6.0 API Level 23为例,对其进行剖析:

public void invalidate(int l, int t, int r, int b) {
    final int scrollX = mScrollX;
    final int scrollY = mScrollY;
    invalidateInternal(l - scrollX, t - scrollY, r - scrollX, b - scrollY, true, false);
}

嗯哼,看到第4行代码的时候,是不是就恍然大悟了呢?
在进行重绘的时候在会利用l - scrollX, t - scrollY, r - scrollX, b - scrollY计算出新的l,t,r,b。
如果在调用scrollTo()和scrollBy()时传入的x,y为正值,那么新的l,t,r,b均会变小,从而导致View的内容向左且向上滚动。
如果在调用scrollTo()和scrollBy()时传入的x,y为负值,那么新的l,t,r,b均会变大,从而导致View的内容向右且向下滚动。

刚才我们通过scrollTo()和scrollBy()作用于某个View,如果要想让多个View同时发生滚动,可以怎么办呢?很简单,只需要把这些View放到同一个ViewGroup中然后再调用这两个方法即可,例如mLinearLayout.scrollBy(50, 0)、mLinearLayout.scrollTo(100, 20)


Scroller原理解析

其实,在实际的开发中我们真正地使用scrollTo()和scrollBy()来实现View的滑动的时候并不多。因为这两个方法产生的滑动是不连贯的,跳跃的,闪烁的,最终的效果也不够平滑。所以,我们多采用系统提供的工具类Scroller来实现View的滚动效果。

关于Scroller的使用我们来瞅瞅Android官方的文档:

This class encapsulates scrolling. You can use scrollers Scroller or OverScroller to collect the data you need to produce a scrolling animation;for example, in response to a fling gesture. Scrollers track scroll offsets for you over time, but they don’t automatically apply those positions to your view. It’s your responsibility to get and apply new coordinates at a rate that will make the scrolling animation look smooth.

Here is a simple example

private Scroller mScroller = new Scroller(context);

public void zoomIn() {
  // Revert any animation currently in progress
  mScroller.forceFinished(true);
  // Start scrolling by providing a starting point and the distance to travel
  mScroller.startScroll(0, 0, 100, 0);
  // Invalidate to request a redraw
  invalidate();
}

To track the changing positions of the x/y coordinates, use computeScrollOffset The method returns a boolean to indicate whether the scroller is finished.If it isn’t, it means that a fling or programmatic pan operation is still in progress.You can use this method to find the current offsets of the x and y coordinates, for example:

if (mScroller.computeScrollOffset()) {
  // Get current x and y positions
  int currX = mScroller.getCurrX();
  int currY = mScroller.getCurrY();
  …
}

Scroller类封装了滑动操作,常用于实现View的平滑滚动并且可使用插值器(Interpolator)设定先加速后减速,先减速后加速等等滑动效果。

依据以上文档的描述可把Scroller的使用概括为以下五个主要步骤:

  1. 初始化Scroller
  2. 调用startScroll()开始滚动
  3. 执行invalidate()刷新界面
  4. 重写View的computeScroll()并在其内部实现与滚动相关的业务逻辑
  5. 再次执行invalidate()刷新界面

现在我们就按照这五个步骤来实现一个小例子:利用Scroller让图片发生滑动。

这里写图片描述

先来看一下它的布局

 <RelativeLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <TextView
        android:id="@+id/textView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_centerHorizontal="true"
        android:layout_marginTop="30dip"
        android:text="谷哥的小弟 http://blog.csdn.net/lfdfhl"
        android:textColor="@android:color/holo_red_dark"
        android:textSize="20sp"
        android:textStyle="normal" />

    <Button
        android:id="@+id/button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_below="@id/textView"
        android:layout_centerHorizontal="true"
        android:layout_marginTop="50dip"
        android:background="#00B9D1"
        android:padding="5dp"
        android:text="点击后滑动"
        android:textAllCaps="false"
        android:textColor="#ffffff"
        android:textSize="25sp" />

    <stay4it.com.LinearLayoutSubClass
        android:id="@+id/linearLayoutSubClass"
        android:layout_width="match_parent"
        android:layout_height="210dp"
        android:layout_below="@id/button"
        android:layout_marginTop="40dp"
        android:background="#bed742">
        <ImageView
            android:layout_width="130dp"
            android:layout_height="130dp"
            android:layout_marginTop="0dp"
            android:scaleType="centerCrop"
            android:src="@drawable/girl" />
    </stay4it.com.LinearLayoutSubClass>

</RelativeLayout>

该布局文件不算复杂,主要的是在自定义的ViewGroup中放入了一个ImageView。既然这样,那就继续来看LinearLayoutSubClass的实现:

/**
 * 原创作者:
 * 谷哥的小弟
 *
 * 博客地址:
 * http://blog.csdn.net/lfdfhl
 */
public class LinearLayoutSubClass extends LinearLayout {
    private Scroller mScroller;
    private boolean flag=true;
    private int offsetY;
    private int duration;
    public LinearLayoutSubClass(Context context) {
        super(context);
    }

    public LinearLayoutSubClass(Context context, AttributeSet attrs) {
        super(context, attrs);
        mScroller=new Scroller(context);
        duration=10000;
    }

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

    public void beginScroll() {
        if (flag) {
            offsetY = -100;
            int startX = -300;
            int startY = -90;
            int dx = -500;
            int dy = 0;
            mScroller.startScroll(startX, startY, dx, dy, duration);
            flag = false;
        } else {
            offsetY = 0;
            int startX = mScroller.getCurrX();
            int startY = mScroller.getCurrX();
            int dx = -startX;
            int dy = 0;
            mScroller.startScroll(startX, startY, dx, dy, duration);
            flag = true;
        }
        invalidate();
    }
}

至此,该示例的主要代码就已经编写完成了,我们只需要在Activity中执行mLinearLayoutSubClass.beginScroll()就可以让图片发生滚动了。

好了,我们依据官方的文档照猫画虎地完成了这个示例,其余的”虎”也和这是非常类似的了。但是,这个Scroller到底是如何让View滚动起来的?为了探个究竟,我们结合文档对刚才的示例做一个详尽的分析。

  • 第一步:初始化Scroller
    在初始化Scroller时可为其设置一个Interpolator,如果没有设置那么系统会采用默认的插值器ViscousFluidInterpolator

  • 第二步:调用startScroll()开始滚动

    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;
    }

    其实,当看到如上的startScroll()源码时我们才发现这个方法也没有让一个View发生滚动,它不过是在给一些字段赋值罢了,比如:动画的开始时间,滑动的开始位置,滑动的距离,滑动的持续时间等等。

  • 第三步:执行invalidate()刷新界面
    在调用invalidate()后会导致View的重绘从而调用computeScroll()

  • 第四步:重写View的computeScroll()并在其内部实现与滚动相关的业务逻辑
    关于该方法的作用,我们还是先来看看源码:

    /**
     * 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 Scroller object.
     */
    public void computeScroll() {
    
    }

    咦,这是个空方法!这就是说我们需要根据自己的业务逻辑重写该方法。其实,该方法的注释已经告诉我们了:如果使用Scroller使得View发生滚动,那么可以在该方法中处理与滑动相关的业务和数据,比如调用scrollTo()或者scrollBy()使得View发生滚动;比如获取变量mScrollX、mScrollY、mCurrX、mCurrY的值。在此有一点需要注意,在处理这些业务和数据之前我们通常需要先利用computeScrollOffset()判断一下滑动是否停止然后再进行相关操作。

  • 第五步:再次执行invalidate()刷新界面
    在处理完与滑动相关的业务和数据后,再次调用invalidate()刷新界面。既然刷新了界面,那么又将导致View的重绘,故又将调用到第四步的computeScroll()方法。所以只要View的滚动没有完成或者未被人为的终止,那么第四步和第五步会一直循环进行。

嗯哼,现在明白了么:Scroller是怎么让View滚动起来的呢?

  1. 利用startScroll()指定了与滑动密切相关的东西,比如时间,距离,Interpolator等。
    有了这些东西我们是不是就可以知道在某个时刻滑动的偏移量和具体的位置了呢?

  2. 在computeScroll()中处理与滑动相关的业务逻辑及其数据
    最常见的操作为利用scrollTo实现View的滚动

    scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
    invalidate();

    这里获取到的mCurrX、mCurrY就是系统依据startScroll()中设置的时间,距离,Interpolator计算出了当前View的滚动数据。
    比如,你告诉我:你家1000米外有个大保健,你计划用5分钟匀速地走到那里。但是你出门后,我发现你忘记带钱包了,于是我就根据你刚才告知我的信息计算出你现在所处的位置,然后用大姜无人机给你送过去就行了。

  3. 调用invalidate()刷新界面,从而再次回到computeScroll()
    回到在computeScroll()继续处理滑动事件。假如View的滑动已经停止了那就没有必要再次执行invalidate()了。

  4. 说到底,不是Scroller让View发生了滚动而是View自己在滚动。
    只不过在这个过程中Scroller在不停地追踪View的滚动,而且提供了许多的辅助而已,比如:可以提供偏移量,耗时,当前位置等等信息。

结合以上的分析,我们再用一张图来梳理一下整个流程

这里写图片描述


Scroller应用示例

在了解了Scroll的工作机制之后,我们来看看Scroller的实际应用。
先来瞅一个布局回弹的效果。

这里写图片描述

在拉动页面后松手,页面弹回到原来的位置。
我们来一起分析该效果的代码实现。

/**
 1. 原创作者:
 2. 谷哥的小弟
 3. 博客地址:
 4. http://blog.csdn.net/lfdfhl
 */
public class BounceableRelativeLayout extends RelativeLayout {
    private Scroller mScroller;
    private GestureDetector mGestureDetector;
    private final String TAG="stay4it";

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

    public BounceableRelativeLayout(Context context, AttributeSet attrs) {
        super(context, attrs);
        setClickable(true);
        setLongClickable(true);
        mScroller = new Scroller(context);
        mGestureDetector = new GestureDetector(context, new GestureListenerImpl());
    }

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

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_UP :
                reset(0, 0);
                break;
            default:
                return mGestureDetector.onTouchEvent(event);
        }
        return super.onTouchEvent(event);
    }


    class GestureListenerImpl implements GestureDetector.OnGestureListener {
        @Override
        public boolean onDown(MotionEvent e) {
            return true;
        }

        @Override
        public void onShowPress(MotionEvent e) {

        }

        @Override
        public boolean onSingleTapUp(MotionEvent e) {
            return false;
        }

        @Override
        public boolean onScroll(MotionEvent e1,MotionEvent e2,float distanceX,float distanceY){
            int disY = (int) ((distanceY - 0.5) / 2);
            beginScroll(0, disY);
            return false;
        }

        public void onLongPress(MotionEvent e) {

        }

        @Override
        public boolean onFling(MotionEvent e1, MotionEvent e2, float x,float y) {
            return false;
        }

    }


    protected void reset(int x, int y) {
        int dx = x - mScroller.getFinalX();
        int dy = y - mScroller.getFinalY();
        beginScroll(dx, dy);
    }

    protected void beginScroll(int dx, int dy) {
        mScroller.startScroll(mScroller.getFinalX(), mScroller.getFinalY(), dx, dy);
        invalidate();
    }
}

结合之前对于Scroller的详细分析,我们可以很容易地梳理出该示例的主要的实现步骤:

  1. 在ACTION_UP时将布局还原到初始位置,请参见第37行代码
  2. 除ACTION_UP以外的事件均由GestureDetector处理,请参见第40行代码
  3. 在GestureDetector的onScroll()处理View的滚动,请参见第64-66行代码
  4. 重写computeScroll()中实现View的滚动,请参见第24-31行代码

在完成了上面的这个例子,我们再来瞅瞅用Scroller实现一个简易版的ViewPager

这里写图片描述

我们来看一下它的具体实现

/**
 1. 原创作者:
 2. 谷哥的小弟
 3. 博客地址:
 4. http://blog.csdn.net/lfdfhl
 */
public class LauncherViewGroup extends ViewGroup {
    private int width;
    private int scrollX;
    private float downX;
    private float moveX;
    private float lastX;
    private int touchSlop;
    private int leftLimit;
    private int rightLimit;
    private Context mContext;
    private Scroller mScroller;
    private final String TAG = "stay4it";

    public LauncherViewGroup(Context context, AttributeSet attrs) {
        super(context, attrs);
        mContext=context;
        width = getResources().getDisplayMetrics().widthPixels;
        mScroller = new Scroller(mContext);
        ViewConfiguration viewConfiguration = ViewConfiguration.get(mContext);
        touchSlop = viewConfiguration.getScaledTouchSlop();
    }

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

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

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        if (changed) {
            int childCount = getChildCount();
            for (int i = 0; i < childCount; i++) {
                View childView = getChildAt(i);
                int left = i * childView.getMeasuredWidth();
                int top = 0;
                int right = (i + 1) * childView.getMeasuredWidth();
                int bottom = childView.getMeasuredHeight();
                childView.layout(left, top, right, bottom);
            }
            leftLimit = getChildAt(0).getLeft();
            rightLimit = getChildAt(childCount - 1).getRight();
        }
    }

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        switch (ev.getAction()) {
            case MotionEvent.ACTION_DOWN:
                downX = ev.getRawX();
                lastX = downX;
                break;
            case MotionEvent.ACTION_MOVE:
                moveX = ev.getRawX();
                float moveDistance = Math.abs(moveX - lastX);
                lastX = moveX;
                if (moveDistance > touchSlop) {
                    return true;
                }
                break;
            case MotionEvent.ACTION_UP:
                break;
        }
        return super.onInterceptTouchEvent(ev);
    }


    @Override
    public boolean onTouchEvent(MotionEvent ev) {
        switch (ev.getAction()) {
            case MotionEvent.ACTION_DOWN:
                break;
            case MotionEvent.ACTION_MOVE:
                moveX = ev.getRawX();
                int moveDistanceX = (int) (lastX - moveX);
                scrollX = getScrollX();
                if (scrollX + moveDistanceX < leftLimit) {
                    scrollTo(leftLimit, 0);
                    return true;
                }

                if (scrollX + moveDistanceX + width > rightLimit) {
                    scrollTo(rightLimit - width, 0);
                    return true;
                }
                scrollBy(moveDistanceX, 0);
                lastX = moveX;
                break;
            case MotionEvent.ACTION_UP:
                scrollX = getScrollX();
                int index = (scrollX + width / 2) / width;
                int distanceX = width * index - scrollX;
                mScroller.startScroll(scrollX, 0, distanceX, 0);
                invalidate();
                break;
        }
        return super.onTouchEvent(ev);
    }
}

在该示例中使用到了自定义View,其中关于onMeasure()和onLayout()以及Touch事件的拦截和分发不再详细分析,对这方面有疑惑的小伙伴请参考Android自定义View系列教程
好了,看完效果我们一起来整理这个示例中的注意事项和关键步骤。

  1. 处理滑动的越界
    一般情况下ViewPager只是展示几张图片而已,所以要确定其左右边界;请参见代码第61-62行

  2. 在onInterceptTouchEvent()中拦截Touch事件
    当发生ACTION_MOVE事件时,假若在X方向滑动的距离大于了系统的TouchSlop则拦截Touch事件将其截留下来由ViewGroup的onTouchEvent()处理;请参见代码第73-80行

  3. 在onTouchEvent中注意滑动越界的处理
    当发生ACTION_MOVE事件时,不论是向左滑还是向右滑,都要防止滑动越界;请参见代码第97-105行

  4. 在onTouchEvent中合理处理ACTION_UP
    当手指抬起时(ACTION_UP)需要判断当前应该滚动到哪一个页。比如,滑动的距离超过了屏幕宽度的一半那么就需要翻页,否则回滚到之前的页面;请参见代码第109-115行

  5. 重写computeScroll( )
    在该方法中实现View的滚动。当然,在scrollTo()不要忘记刷新View;请参见代码第29-37行

后语

在这篇博客中,我们从scrollTo()和scrollBy()入手,从源码角度分析了View的滚动的具体实现;并在此基础上对Scroller的工作机制作了完整的介绍和梳理。文中的几个示例力求以最简洁的代码阐明其背后的稍显晦涩的缘由,以达深刻认识之目的。

望小伙伴们在读完此篇博客之后,有所思,有所悟,有所获。


PS:如果觉得文章太长,你也可观看该课程的视频教程,亲,里面还有高清,无码的福利喔


以上是关于站在源码的肩膀上全解Scroller工作机制的主要内容,如果未能解决你的问题,请参考以下文章

谷哥的小弟学后台(02)——MySQL

谷哥的小弟学后台(01)——MySQL

谷哥的小弟学后台(04)——MySQL

站在巨人的肩膀上 -- Retrofit源码解析

站在巨人的肩膀上 -- Retrofit源码解析

站在巨人的肩膀上 -- Retrofit源码解析