Scroll滚动原理

介绍

Android里Scroller类是为了实现View平滑滚动的一个Helper类。通常在自定义的View时使用,在View中定义一个私有成员mScroller = new Scroller(context)。
设置mScroller滚动的位置时startScroll,并不会导致View的滚动,通常是用mScroller记录/计算View滚动的位置,再重写View的computeScroll(),完成实际的滚动。

/*如果想对某个View(例如Button)进行滚动,我们直接调用该View(Button)的scrollBy()方法,并不是该View(Button)进行滚动,而是该View里面的内容(Button上面的文字)进行滚动,所以我们假如要让View整体滚动就需要对其View的父布局调用scrollBy()方法。参考《Android 带你从源码的角度解析Scroller的滚动实现原理》这篇博客中的。*/

从别人博客看到这句话一直深信不疑,后来发现这是错误的。。如果自定义的view直接继承自view,viewgroup直接继承自viewgroup,那么scrollBy(),就是整体滚动了。。参考《Android 动画原理》 中的scroll章节分析。。

但是如果是使用系统自定义的比如LinearLayout,Button等,滚动的就是其中的内容了,而不是整体滚动了,因为这些系统自定义的view内部修改了实现,具体怎么做到的,还不知道。

比如myviewgroup.scrollby(-10,-10)  myviewgroup直接继承自viewgroup,那么整体右下移动10个单位

Linearlayout.scrollby(-10,-10),linearyout位置不变,只是子view移动10个单位

举个例子<p>activity_main.xml</p><pre name="code" class="java"><LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/layout"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:gravity="center">

    <Button
        android:id="@+id/scroll_but"
        android:layout_width="200dp"
        android:layout_height="200dp"
        android:background="#b3efe8"
        android:text="Scroll But"/>

</LinearLayout>



public class MainActivity extends Activity {
    private LinearLayout layout;
    private Button scrollBut;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        layout = (LinearLayout) findViewById(R.id.layout);
        scrollBut = (Button) findViewById(R.id.scroll_but);
        scrollBut.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                layout.scrollBy(-10, 0);
                //scrollBut.scrollBy(-10, 0);
            }
        });
    }
}

这样的话,如果是layout.scrollBy,那么移动是里面的view(Button)。如果是scrollBut.scrollBy,那么移动的将是button里面的内容。

同时如果为x是负数,将向右移动,正数将向左移动,同理Y轴的移动也类似。这样看似反逻辑,为什么是这样,看以下分析


源码分析

View类的scrollTo()和scrollBy()源码

首先都是View的方法,作用在View上,scrollBy实际上调用了scrollTo方法,两者区别联系一目了然,scrollTo是滚动到具体的坐标,scrollBy是在前一次的基础上滚动。

同时需要注意的是此时设置了mScrollX,mScrollY。而View的getScrollX,getScrollY返回的恰好就是这两个值。同时scrollTo()方法中调用了invalidate,会导致该view所在的viewgroup发生了重绘,类似于动画。都是所在的viewgroup发生了重绘而不是view本身。

 

/**
      * Used to indicate that the parent of this view should clear its caches. This functionality
      * is used to force the parent to rebuild its display list (when hardware-accelerated),
      * which is necessary when various parent-managed properties of the view change, such as
      * alpha, translationX/Y, scrollX/Y, scaleX/Y, and rotation/X/Y. This method only
      * clears the parent caches and does not causes an invalidate event.
      *
      * @hide
      */ invalidateParentCaches();
  postInvalidateOnAnimation();

这两行代码导致view所在的viewgroup发生了重绘。。


scrollTo()

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



scrollBy()

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


调用了该view(viewgroup)的parent(所在viewgroup的)的draw方法。

public void draw(Canvas canvas) {
        final int privateFlags = mPrivateFlags;
        final boolean dirtyOpaque = (privateFlags & PFLAG_DIRTY_MASK) == PFLAG_DIRTY_OPAQUE &&
                (mAttachInfo == null || !mAttachInfo.mIgnoreDirtyState);
        mPrivateFlags = (privateFlags & ~PFLAG_DIRTY_MASK) | PFLAG_DRAWN;

        /*
         * Draw traversal performs several drawing steps which must be executed
         * in the appropriate order:
         *
         *      1. Draw the background
         *      2. If necessary, save the canvas' layers to prepare for fading
         *      3. Draw view's content
         *      4. Draw children
         *      5. If necessary, draw the fading edges and restore layers
         *      6. Draw decorations (scrollbars for instance)
         */

        // Step 1, draw the background, if needed
        int saveCount;

        if (!dirtyOpaque) {
            drawBackground(canvas);
        }

        // skip step 2 & 5 if possible (common case)
        final int viewFlags = mViewFlags;
        boolean horizontalEdges = (viewFlags & FADING_EDGE_HORIZONTAL) != 0;
        boolean verticalEdges = (viewFlags & FADING_EDGE_VERTICAL) != 0;
        if (!verticalEdges && !horizontalEdges) {
            // Step 3, draw the content
            if (!dirtyOpaque) onDraw(canvas);

            // Step 4, draw the children
            dispatchDraw(canvas);



boolean draw(Canvas canvas, ViewGroup parent, long drawingTime) {
        ……
        int sx = 0;
        int sy = 0;
        if (!drawingWithRenderNode) {
            computeScroll();//这里调用了computeScroll方法
            sx = mScrollX;
            sy = mScrollY;
        }

        final boolean drawingWithDrawingCache = cache != null && !drawingWithRenderNode;
        final boolean offsetForScroll = cache == null && !drawingWithRenderNode;

        int restoreTo = -1;
        if (!drawingWithRenderNode || transformToApply != null) {
            restoreTo = canvas.save();
        }
        if (offsetForScroll) {
            canvas.translate(mLeft - sx, mTop - sy);
        } else {
        ……
        }
}

此时可以看到,绘制每个子view的时候,都可能执行canvas.translate(mLeft - sx, mTop - sy)。 所以是整个被移动, 而sx被mScrollX赋值,sy被mScrollY赋值。所以此时scrollTo(x,y)中x为负数,反而往右滚动了。

此时就是解释了两点:

1. x为负数,向右滚动,正数,向左滚动

2. 默认情况调用scrollby,scrollto方法都是整个移动的。


Scroller类源码

Android里Scroller类是为了实现View平滑滚动的一个Helper类。实际上如果不通过Scroller类,只是通过scrollTo,scrollBy方法滚动,并不是平滑的滚动,而是瞬间的滚动。下面介绍Scroller怎么实现了平滑滚动的效果

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

startX,startY起始坐标,dx,dy滚动的偏移值,duration平滑滚动的时间。所以必须调用startScroll方法先进性参数的设置,才会有平滑滚动的效果


computeScrollOffset方法

public boolean computeScrollOffset() {
        if (mFinished) {
            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);
                mCurrY = mStartY + Math.round(x * mDeltaY);
                break;
            case FLING_MODE:
                final float t = (float) timePassed / mDuration;
                final int index = (int) (NB_SAMPLES * t);
                float distanceCoef = 1.f;
                float velocityCoef = 0.f;
                if (index < NB_SAMPLES) {
                    final float t_inf = (float) index / NB_SAMPLES;
                    final float t_sup = (float) (index + 1) / NB_SAMPLES;
                    final float d_inf = SPLINE_POSITION[index];
                    final float d_sup = SPLINE_POSITION[index + 1];
                    velocityCoef = (d_sup - d_inf) / (t_sup - t_inf);
                    distanceCoef = d_inf + (t - t_inf) * velocityCoef;
                }

                mCurrVelocity = velocityCoef * mDistance / mDuration * 1000.0f;
                
                mCurrX = mStartX + Math.round(distanceCoef * (mFinalX - mStartX));
                // Pin to mMinX <= mCurrX <= mMaxX
                mCurrX = Math.min(mCurrX, mMaxX);
                mCurrX = Math.max(mCurrX, mMinX);
                
                mCurrY = mStartY + Math.round(distanceCoef * (mFinalY - mStartY));
                // Pin to mMinY <= mCurrY <= mMaxY
                mCurrY = Math.min(mCurrY, mMaxY);
                mCurrY = Math.max(mCurrY, mMinY);

                if (mCurrX == mFinalX && mCurrY == mFinalY) {
                    mFinished = true;
                }

                break;
            }
        }
        else {
            mCurrX = mFinalX;
            mCurrY = mFinalY;
            mFinished = true;
        }
        return true;
    }

我们在startScroll()方法的时候获取了当前的动画毫秒赋值给了mStartTime,在computeScrollOffset()中再一次调用AnimationUtils.currentAnimationTimeMillis()来获取动画。毫秒减去mStartTime就是持续时间了,然后进去if判断,如果动画持续时间小于我们设置的滚动持续时间mDuration,进去switch的SCROLL_MODE,然后根据Interpolator来计算出在该时间段里面移动的距离,赋值给mCurrX, mCurrY, 所以该方法的作用是,计算在0到mDuration时间段内滚动的偏移量,并且判断滚动是否结束,true代表还没结束,false则表示滚动结束了


computeScroll方法

从上面可以看到,ViewGroup中dispatchDraw绘制子view,遍历去调用每个子view的child.draw(canvas, this, drawingTime)方法,在这个方法里面有执行,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 {@link android.widget.Scroller Scroller}
     * object.
     */
    public void computeScroll() {
    }

这里面是一个空实现,所以如果需要实现平滑滚动的功能,必须自己实现这个方法


最后总结一下:

View平滑滚动的实现原理,我们先调用Scroller的startScroll()方法来进行一些滚动的初始化设置,然后迫使View进行绘制,我们调用View的invalidate()或postInvalidate()就可以重新绘制View所在的viewgroup,绘制子View的时候会触发computeScroll()方法,我们重写computeScroll(),在computeScroll()里面先调用Scroller的computeScrollOffset()方法来判断滚动有没有结束,如果滚动没有结束我们就调用scrollTo()方法来进行滚动,该scrollTo()方法虽然会重新绘制View,但是我们还是要手动调用下invalidate()或者postInvalidate()来触发界面重绘,重新绘制View又触发computeScroll(),所以就进入一个循环阶段,这样子就实现了在某个时间段里面滚动某段距离的一个平滑的滚动效果。

也许有人会问,干嘛还要调用来调用去最后在调用scrollTo()方法,还不如直接调用scrollTo()方法来实现滚动,其实直接调用是可以,只不过scrollTo()是瞬间滚动的,给人的用户体验不太好,所以Android提供了Scroller类实现平滑滚动的效果

Scroller使用例子

很好的学习例子,注意此时viewgroup继承的是linearlayout所以scrollby,scrollto方法,移动的是里面的子view。viewgroup的位置不变。。

主布局

<com.ipjmc.scroller.CustomView xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/custom_view"
    android:layout_width="fill_parent"
    android:layout_height="fill_parent"
    android:background="#fff"
    android:orientation="vertical" >

    <TextView
        android:layout_width="fill_parent"
        android:layout_height="0dp"
        android:layout_weight="1"
        android:background="#aaa"
        android:gravity="center_horizontal"
        android:text="下拉1"
        android:textSize="32sp" />

    <TextView
        android:layout_width="fill_parent"
        android:layout_height="0dp"
        android:layout_weight="1"
        android:background="#ff00ff"
        android:gravity="center_horizontal"
        android:text="下拉2"
        android:textSize="32sp" />

</com.ipjmc.scroller.CustomView>



自定义的ViewGroup

public class CustomView extends LinearLayout {

	private static final String TAG = "LiaBin";

	private Scroller mScroller;
	private GestureDetector mGestureDetector;

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

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

	// 调用此方法滚动到目标位置
	public void smoothScrollTo(int fx, int fy) {
		int dx = fx - mScroller.getFinalX();
		int dy = fy - mScroller.getFinalY();
		smoothScrollBy(dx, dy);
	}

	// 调用此方法设置滚动的相对偏移
	public void smoothScrollBy(int dx, int dy) {

		// 设置mScroller的滚动偏移量
		mScroller.startScroll(mScroller.getFinalX(), mScroller.getFinalY(), dx, dy);
		invalidate();// 这里必须调用invalidate()才能保证computeScroll()会被调用,否则不一定会刷新界面,看不到滚动效果
	}

	@Override
	public void computeScroll() {

		// 先判断mScroller滚动是否完成
		if (mScroller.computeScrollOffset()) {

			// 这里调用View的scrollTo()完成实际的滚动
			scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
			// 必须调用该方法,否则不一定能看到滚动效果
			postInvalidate();
		}
		super.computeScroll();
	}

	@Override
	public boolean onTouchEvent(MotionEvent event) {
		switch (event.getAction()) {
		case MotionEvent.ACTION_UP:
			Log.i(TAG, "get Sy" + getScrollY());
			smoothScrollTo(0, 0);
			break;
		default:
			return mGestureDetector.onTouchEvent(event);
		}
		return super.onTouchEvent(event);
	}

	class CustomGestureListener extends GestureDetector.SimpleOnGestureListener {
		@Override
		public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {

			//下拉的时候,distanceY是负的,因为distanceY是上一次ACTION_MOVE事件和这一次ACTION_MOVE事件在Y坐标的差值,
			//所以view下拉会向下移动,同时((distanceY - 0.5) / 2)如果不这样处理,view下拉距离就很大。
			int dis = (int) ((distanceY - 0.5) / 2);
			Log.i(TAG, distanceY + ".");
			smoothScrollBy(0, dis);
			return false;
		}
	}
}

这里能够实现下拉滚动的关键点在于:

// 设置mScroller的滚动偏移量
         mScroller.startScroll(mScroller.getFinalX(), mScroller.getFinalY(), dx, dy);
         invalidate();// 这里必须调用invalidate()才能保证computeScroll()会被调用,否则不一定会刷新界面,看不到滚动效果        startScroll里面根据动画时间来设置mCurrX,mCurrY参数,滚动偏移量

然后scrollTo(mScroller.getCurrX(), mScroller.getCurrY());实现了平滑的滚动   

@Override
     public void computeScroll() {
         // 先判断mScroller滚动是否完成
         if (mScroller.computeScrollOffset()) {
             // 这里调用View的scrollTo()完成实际的滚动
             scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
             // 必须调用该方法,否则不一定能看到滚动效果
             postInvalidate();
         }
         super.computeScroll();
     }

ScrollView介绍

ScrollView  竖直滚动
HorizontalScrollView  水平滚动
继承子FrameLayout,允许比物理显示还要大,用户可以滚动。不要跟ListView一起使用,因为ListView处理自己的滚动事件,还有TextView

Layout container for a view hierarchy that can be scrolled by the user, allowing it to be larger than the physical display.
A ScrollView is a FrameLayout, meaning you should place one child in it containing the entire contents to scroll;
this child may itself be a layout manager with a complex hierarchy of objects. A child that is often used is a LinearLayout in a vertical orientation,
presenting a vertical array of top-level items that the user can scroll through.

You should never use a ScrollView with a ListView, because ListView takes care of its own vertical scrolling. Most importantly,
doing this defeats all of the important optimizations in ListView for dealing with large lists, since it effectively forces the ListView to display
its entire list of items to fill up the infinite container supplied by ScrollView.

The TextView class also takes care of its own scrolling, so does not require a ScrollView,
but using the two together is possible to achieve the effect of a text view within a larger container.