Android Scroller完全解析

书到用时方恨少,事非经过不知难。这篇文章主要讲述Android Scroller完全解析相关的知识,希望能为你提供帮助。
scrollTo()和scrollBy() 在android中, 任何一个控件都是可以滚动的, 因为在View类当中有scrollTo()和scrollBy()这两个方法, 如下图所示:

Android Scroller完全解析

文章图片

这两个方法的主要作用是将View/ViewGroup移至指定的坐标中, 并且将偏移量保存起来。另外:
  • mScrollX 代表X轴方向的偏移坐标
  • mScrollY 代表Y轴方向的偏移坐标
这两个方法都是用于对View进行滚动的, 那么它们之间有什么区别呢? 简单点讲, scrollBy()方法是让View相对于当前的位置滚动某段距离, 而scrollTo()方法则是让View相对于初始的位置滚动某段距离。
关于偏移量的设置我们可以参看下源码:
public class View { .... protected int mScrollX; //该视图内容相当于视图起始坐标的偏移量,X轴方向 protected int mScrollY; //该视图内容相当于视图起始坐标的偏移量,Y轴方向 //返回值 public final int getScrollX() { return mScrollX; } public final int getScrollY() { return mScrollY; } public void scrollTo(int x, int y) { //偏移位置发生了改变 if (mScrollX != x || mScrollY != y) { int oldX = mScrollX; int oldY = mScrollY; mScrollX = x; //赋新值, 保存当前便宜量 mScrollY = y; //回调onScrollChanged方法 onScrollChanged(mScrollX, mScrollY, oldX, oldY); if (!awakenScrollBars()) { invalidate(); //一般都引起重绘 } } } // 看出区别了吧 。 mScrollX 与 mScrollY 代表我们当前偏移的位置 , 在当前位置继续偏移(x ,y)个单位 public void scrollBy(int x, int y) { scrollTo(mScrollX + x, mScrollY + y); } //... }

于是, 在任何时刻我们都可以获取该View/ViewGroup的偏移位置了, 即调用getScrollX()方法和getScrollY()方法。
下面我们写个例子看下它们的区别吧:
< ?xml version= " 1.0" encoding= " utf-8" ?> < LinearLayout xmlns:android= " http://schemas.android.com/apk/res/android" xmlns:tools= " http://schemas.android.com/tools" android:id= " @ + id/layout" android:layout_width= " match_parent" android:layout_height= " match_parent" android:orientation= " vertical" > < Button android:id= " @ + id/scroll_to_btn" android:layout_width= " wrap_content" android:layout_height= " wrap_content" android:text= " scrollTo" /> < Button android:id= " @ + id/scroll_by_btn" android:layout_width= " wrap_content" android:layout_height= " wrap_content" android:layout_marginTop= " 10dp" android:text= " scrollBy" /> < /LinearLayout>

【Android Scroller完全解析】外层使用了一个LinearLayout, 在里面包含了两个按钮, 一个用于触发scrollTo逻辑, 一个用于触发scrollBy逻辑。
public class MainActivity extends AppCompatActivity {private LinearLayout layout; private Button scrollToBtn; private Button scrollByBtn; @ Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); layout = (LinearLayout) findViewById(R.id.layout); scrollToBtn = (Button) findViewById(R.id.scroll_to_btn); scrollByBtn = (Button) findViewById(R.id.scroll_by_btn); scrollToBtn.setOnClickListener(new View.OnClickListener() { @ Override public void onClick(View v) { layout.scrollTo(getResources().getDimensionPixelOffset(R.dimen.horizontal_scroll), getResources().getDimensionPixelOffset(R.dimen.horizontal_scroll)); } }); scrollByBtn.setOnClickListener(new View.OnClickListener() { @ Override public void onClick(View v) { layout.scrollBy(getResources().getDimensionPixelOffset(R.dimen.horizontal_scroll), getResources().getDimensionPixelOffset(R.dimen.horizontal_scroll)); } }); } }

< resources> < dimen name= " horizontal_scroll" > -40dp< /dimen> < dimen name= " vertical_scroll" > -80dp< /dimen> < /resources>

当点击了scrollTo按钮时, 我们调用了LinearLayout的scrollTo()方法, 当点击了scrollBy按钮时, 调用了LinearLayout的scrollBy()方法。那有的朋友可能会问了, 为什么都是调用的LinearLayout中的scroll方法? 这里一定要注意, 不管是scrollTo()还是scrollBy()方法, 滚动的都是该View内部的内容, 而LinearLayout中的内容就是我们的两个Button, 如果你直接调用button的scroll方法的话, 那滚动的就是button中包含的内容, 而不是button本身。
Android Scroller完全解析

文章图片

如图, 中间的矩形相当于屏幕, 即可视区域。后面的content相当于画布, 代表视图。大家可以看到, 只有视图中间部分目前是可见的, 其他部分不可见, 可见部分我们设置了一个button, 坐标( 20,10) 。下面使用scrollBy()方法将可是区域在屏幕上向X轴正方向( 右) 平移20, 在Y正方向( 下) 平移10, 平移后的视图如图5.6, 可以发现虽然scrollBy(20,10), 均为正方向, 但Button却向X, Y负方向移动了, 这就是选择的参考系不同而产生不同效果。
为了让Button向X,Y正方向移动, 我们设置上面DEMO中X, Y的移动距离分别为-40dp, -80dp。运行一下程序:
Android Scroller完全解析

文章图片

当我们点击scrollTo按钮时, 两个按钮会一起向右下方滚动, 之后再点击scrollTo按钮就没有任何作用了, 界面不会再继续滚动, 只有点击scrollBy按钮界面才会继续滚动, 并且不停点击scrollBy按钮界面会一起滚动下去。
Scroller类 从上面例子运行结果可以看出, 利用scrollTo()/scrollBy()方法把一个View偏移至指定坐标(x,y)处, 整个过程是直接跳跃的, 没有对这个偏移过程有任何控制, 对用户而言不太友好。于是, 基于这种偏移控制, Scroller类被设计出来了, 该类的主要作用是为偏移过程制定一定的控制流程, 从而使偏移更流畅, 更完美。
我们分析下源码里去看看Scroller类的相关方法, 其源代码(部分)如下: 路径位于 \\frameworks\\base\\core\\java\\android\\widget\\Scroller.java
public class Scroller{private int mStartX; //起始坐标点 ,X轴方向 private int mStartY; //起始坐标点 ,Y轴方向 private int mCurrX; //当前坐标点X轴, 即调用startScroll函数后,经过一定时间所达到的值 private int mCurrY; //当前坐标点Y轴, 即调用startScroll函数后,经过一定时间所达到的值private float mDeltaX; //应该继续滑动的距离, X轴方向 private float mDeltaY; //应该继续滑动的距离, Y轴方向 private boolean mFinished; //是否已经完成本次滑动操作, 如果完成则为 true//构造函数 public Scroller(Context context) { this(context, null); } public final boolean isFinished() { return mFinished; } //强制结束本次滑屏操作 public final void forceFinished(boolean finished) { mFinished = finished; } public final int getCurrX() { return mCurrX; } /* Call this when you want to know the new location.If it returns true, * the animation is not yet finished.loc will be altered to provide the * new location. */ //根据当前已经消逝的时间计算当前的坐标点, 保存在mCurrX和mCurrY值中 public boolean computeScrollOffset() { if (mFinished) {//已经完成了本次动画控制, 直接返回为false return false; } int timePassed = (int)(AnimationUtils.currentAnimationTimeMillis() - mStartTime); if (timePassed < mDuration) { switch (mMode) { case SCROLL_MODE: float x = (float)timePassed * mDurationReciprocal; ... mCurrX = mStartX + Math.round(x * mDeltaX); mCurrY = mStartY + Math.round(x * mDeltaY); break; ... } else { mCurrX = mFinalX; mCurrY = mFinalY; mFinished = true; } return true; } //开始一个动画控制, 由(startX , startY)在duration时间内前进(dx,dy)个单位, 即到达坐标为(startX+ dx , startY+ dy)出 public void startScroll(int startX, int startY, int dx, int dy, int duration) { mFinished = false; mDuration = duration; mStartTime = AnimationUtils.currentAnimationTimeMillis(); mStartX = startX; mStartY = startY; mFinalX = startX + dx; mFinalY = startY + dy; mDeltaX = dx; mDeltaY = dy; ... } }

其中比较重要的两个方法为:
  • public boolean computeScrollOffset()
函数功能说明: 根据当前已经消逝的时间计算当前的坐标点, 保存在mCurrX和mCurrY值中。
  • public void startScroll(int startX, int startY, int dx, int dy, int duration)
函数功能说明: 开始一个动画控制, 由(startX , startY)在duration时间内前进(dx,dy)个单位, 到达坐标为(startX+ dx , startY+ dy)处。
computeScroll() 方法介绍:
为了易于控制滑屏控制, Android框架提供了 computeScroll()方法去控制这个流程。在绘制View时, 会在draw()过程调用该方法。因此, 再配合使用Scroller实例, 我们就可以获得当前应该的偏移坐标, 手动使View/ViewGroup偏移至该处。
computeScroll()方法原型如下, 该方法位于ViewGroup.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. * 由父视图调用用来请求子视图根据偏移值 mScrollX,mScrollY重新绘制*/ public void computeScroll() { //空方法 , 自定义ViewGroup必须实现方法体 }

为了实现偏移控制, 一般自定义View/ViewGroup都需要重载该方法 。其调用过程位于View绘制流程draw()过程中, 如下:
@ Override protected void dispatchDraw(Canvas canvas){ ...for (int i = 0; i < count; i+ + ) { final View child = children[getChildDrawingOrder(count, i)]; if ((child.mViewFlags & VISIBILITY_MASK) = = VISIBLE || child.getAnimation() != null) { more |= drawChild(canvas, child, drawingTime); } } } protected boolean drawChild(Canvas canvas, View child, long drawingTime) { ... child.computeScroll(); ... }

实例演示 ViewPager相信每个人都再熟悉不过了, 因此它实在是太常用了, 我们可以借助ViewPager来轻松完成页面之间的滑动切换效果, 但是如果问到它是如何实现的话, 我感觉大部分人还是比较陌生的。其实说到ViewPager最基本的实现原理主要就是两部分内容, 一个是事件分发, 一个是Scroller。对于事件分发, 不了解的同学可以参考我这篇博客Android事件的分发、拦截和执行。
接下来我将结合事件分发和Scroller来实现一个简易版的ViewPager。首先自定义一个ViewGroup, 不了解的可以参考Android自定义ViewGroup( 一) 之CustomGridLayout这篇文章。平滑偏移的主要做法如下:
  • 第一、调用Scroller实例去产生一个偏移控制(对应于startScroll()方法)
  • 第二、手动调用invalid()方法去重新绘制, 剩下的就是在computeScroll()里根据当前已经逝去的时间, 获取当前应该偏移的坐标(由Scroller实例对应的computeScrollOffset()计算而得)
  • 第三、根据当前应该偏移的坐标, 调用scrollBy()方法去缓慢移动至该坐标处。
新建一个ScrollerLayout并让它继承自ViewGroup来作为我们的简易ViewPager布局, 代码如下所示:
public class ScrollerLayout extends ViewGroup {private Scroller mScroller; //用于完成滚动操作的实例 private VelocityTracker mVelocityTracker = null ; //处理触摸的速率 public static int SNAP_VELOCITY = 600 ; //最小的滑动速率 private int mTouchSlop = 0 ; //最小滑动距离, 超过了, 才认为开始滑动 private float mLastionMotionX = 0 ; //上次触发ACTION_MOVE事件时的屏幕坐标 private int curScreen = 0 ; //当前屏幕 private int leftBorder; //界面可滚动的左边界 private int rightBorder; //界面可滚动的右边界//两种状态: 是否处于滑屏状态 private static final int TOUCH_STATE_REST = 0; //什么都没做的状态 private static final int TOUCH_STATE_SCROLLING = 1; //开始滑屏的状态 private int mTouchState = TOUCH_STATE_REST; //默认是什么都没做的状态public ScrollerLayout(Context context, AttributeSet attrs) { super(context, attrs); // 创建Scroller的实例 mScroller = new Scroller(context); //初始化一个最小滑动距离 mTouchSlop = ViewConfiguration.get(context).getScaledTouchSlop(); }@ 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); // 为ScrollerLayout中的每一个子控件测量大小 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); // 为ScrollerLayout中的每一个子控件在水平方向上进行布局 childView.layout(i * childView.getMeasuredWidth(), 0, (i + 1) * childView.getMeasuredWidth(), childView.getMeasuredHeight()); } } // 初始化左右边界值 leftBorder = getChildAt(0).getLeft(); rightBorder = getChildAt(getChildCount() - 1).getRight(); }@ 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_MOVE: final int xDiff = (int) Math.abs(mLastionMotionX - x); //超过了最小滑动距离, 就可以认为开始滑动了 if (xDiff > mTouchSlop) { mTouchState = TOUCH_STATE_SCROLLING; } break; case MotionEvent.ACTION_DOWN: mLastionMotionX = x; mTouchState = mScroller.isFinished() ? TOUCH_STATE_REST : TOUCH_STATE_SCROLLING; break; case MotionEvent.ACTION_CANCEL: case MotionEvent.ACTION_UP: mTouchState = TOUCH_STATE_REST; break; } return mTouchState != TOUCH_STATE_REST; }public boolean onTouchEvent(MotionEvent event){ super.onTouchEvent(event); //获得VelocityTracker对象, 并且添加滑动对象 if (mVelocityTracker = = null) { mVelocityTracker = VelocityTracker.obtain(); } mVelocityTracker.addMovement(event); //触摸点 float x = event.getX(); switch(event.getAction()){ case MotionEvent.ACTION_DOWN: //如果屏幕的动画还没结束, 你就按下了, 我们就结束上一次动画, 即开始这次新ACTION_DOWN的动画 if(mScroller != null){ if(!mScroller.isFinished()){ mScroller.abortAnimation(); } } mLastionMotionX = x ; //记住开始落下的屏幕点 break ; case MotionEvent.ACTION_MOVE: int detaX = (int)(mLastionMotionX - x ); //每次滑动屏幕, 屏幕应该移动的距离 if (getScrollX() + detaX < leftBorder) {//防止用户拖出边界这里还专门做了边界保护, 当拖出边界时就调用scrollTo()方法来回到边界位置 scrollTo(leftBorder, 0); return true; } else if (getScrollX() + getWidth() + detaX > rightBorder) { scrollTo(rightBorder - getWidth(), 0); return true; } scrollBy(detaX, 0); //开始缓慢滑屏咯。 detaX > 0 向右滑动 , detaX < 0 向左滑动 mLastionMotionX = x ; break ; case MotionEvent.ACTION_UP: final VelocityTracker velocityTracker = mVelocityTracker; velocityTracker.computeCurrentVelocity(1000); //计算速率 int velocityX = (int) velocityTracker.getXVelocity() ; //滑动速率达到了一个标准(快速向右滑屏, 返回上一个屏幕) 马上进行切屏处理 if (velocityX > SNAP_VELOCITY & & curScreen > 0) { // Fling enough to move left snapToScreen(curScreen - 1); } //快速向左滑屏, 返回下一个屏幕 else if(velocityX < -SNAP_VELOCITY & & curScreen < (getChildCount()-1)){ snapToScreen(curScreen + 1); } //以上为快速移动的 , 强制切换屏幕 else{ //我们是缓慢移动的, 因此先判断是保留在本屏幕还是到下一屏幕 snapToDestination(); } //回收VelocityTracker对象 if (mVelocityTracker != null) { mVelocityTracker.recycle(); mVelocityTracker = null; } //修正mTouchState值 mTouchState = TOUCH_STATE_REST ; break; case MotionEvent.ACTION_CANCEL: mTouchState = TOUCH_STATE_REST ; break; } return true ; }//我们是缓慢移动的, 因此需要根据偏移值判断目标屏是哪个 private void snapToDestination(){ //判断是否超过下一屏的中间位置, 如果达到就抵达下一屏, 否则保持在原屏幕 //公式意思是: 假设当前滑屏偏移值即 scrollCurX 加上每个屏幕一半的宽度, 除以每个屏幕的宽度就是我们目标屏所在位置了。 int destScreen = (getScrollX() + getWidth() / 2 ) / getWidth() ; snapToScreen(destScreen); }//真正的实现跳转屏幕的方法 private void snapToScreen(int whichScreen){ //简单的移到目标屏幕, 可能是当前屏或者下一屏幕, 直接跳转过去, 不太友好, 为了友好性, 我们在增加一个动画效果 curScreen = whichScreen ; //防止屏幕越界, 即超过屏幕数 if(curScreen > getChildCount() - 1) curScreen = getChildCount() - 1 ; //为了达到下一屏幕或者当前屏幕, 我们需要继续滑动的距离.根据dx值, 可能向左滑动, 也可能向右滑动 int dx = curScreen * getWidth() - getScrollX() ; mScroller.startScroll(getScrollX(), 0, dx, 0, Math.abs(dx) * 2); //由于触摸事件不会重新绘制View, 所以此时需要手动刷新View 否则没效果 invalidate(); }@ Override public void computeScroll() { //重写computeScroll()方法, 并在其内部完成平滑滚动的逻辑 if (mScroller.computeScrollOffset()) { scrollTo(mScroller.getCurrX(), mScroller.getCurrY()); invalidate(); } } }

代码比较长, 但思路比较清晰。
( 1) 首先在ScrollerLayout的构造函数里面我们创建Scroller的实例, 由于Scroller的实例只需创建一次, 因此我们把它放到构造函数里面执行。另外在构建函数中我们还初始化的TouchSlop的值, 这个值在后面将用于判断当前用户的操作是否是拖动。
( 2) 接着重写onMeasure()方法和onLayout()方法, 在onMeasure()方法中测量ScrollerLayout里的每一个子控件的大小, 在onLayout()方法中为ScrollerLayout里的每一个子控件在水平方向上进行布局, 布局类似于方向为horizontal的LinearLayout, 然后获取ViewGroup的左右边界位置。
( 3) 接着重写onInterceptTouchEvent()方法, 在这个方法中我们记录了用户手指按下时的X坐标位置, 以及用户手指在屏幕上拖动时的X坐标位置, 当两者之间的距离大于TouchSlop值时, 就认为用户正在拖动布局, 置状态为TOUCH_STATE_SCROLLING, 当用户手指抬起, 重置状态为TOUCH_STATE_REST。这里当状态值为TOUCH_STATE_SCROLLING时返回true, 将事件在这里拦截掉, 阻止事件传递到子控件当中。
( 4) 那么当我们把事件拦截掉之后, 就会将事件交给ScrollerLayout的onTouchEvent()方法来处理。
如果当前事件是ACTION_MOVE, 说明用户正在拖动布局, 那么我们就应该对布局内容进行滚动从而影响拖动事件, 实现的方式就是使用我们刚刚所学的scrollBy()方法, 用户拖动了多少这里就scrollBy多少。另外为了防止用户拖出边界这里还专门做了边界保护, 当拖出边界时就调用scrollTo()方法来回到边界位置。
如果当前事件是ACTION_UP时, 说明用户手指抬起来了, 但是目前很有可能用户只是将布局拖动到了中间, 我们不可能让布局就这么停留在中间的位置, 因此接下来就需要借助Scroller来完成后续的滚动操作。首先计算滚动速率, 判断当前动作是scroll还是fling。如果是fling, 再根据fling的方向跳转到上一页或者下一页, 调用函数snapToScreen。如果是scroll, 就调用函数snapToDestination, 函数中首先根据当前的滚动位置来计算布局应该继续滚动到哪一页, 滚动到哪一页同样调用snapToScreen。再来看看snapToScreen写法吧, 其实是调用startScroll()方法来滚动数据, 紧接着调用invalidate()方法来刷新界面。
( 5) 重写computeScroll()方法, 并在其内部完成平滑滚动的逻辑 。在整个后续的平滑滚动过程中, computeScroll()方法是会一直被调用的, 因此我们需要不断调用Scroller的computeScrollOffset()方法来进行判断滚动操作是否已经完成了, 如果还没完成的话, 那就继续调用scrollTo()方法, 并把Scroller的curX和curY坐标传入, 然后刷新界面从而完成平滑滚动的操作。
现在ScrollerLayout已经准备好了, 接下来我们修改activity_main.xml布局中的内容, 如下所示:
< ?xml version= " 1.0" encoding= " utf-8" ?> < com.hx.scroller.ScrollerLayout xmlns:android= " http://schemas.android.com/apk/res/android" android:layout_width= " match_parent" android:layout_height= " match_parent" > < ImageView android:layout_width= " match_parent" android:layout_height= " 200dp" android:background= " @ drawable/bg_1" /> < ImageView android:layout_width= " match_parent" android:layout_height= " 200dp" android:background= " @ drawable/bg_2" /> < ImageView android:layout_width= " match_parent" android:layout_height= " 200dp" android:background= " @ drawable/bg_3" /> < ImageView android:layout_width= " match_parent" android:layout_height= " 200dp" android:background= " @ drawable/bg_4" /> < /com.hx.scroller.ScrollerLayout>

运行一下程序来看一看效果了, 如下图所示:
Android Scroller完全解析

文章图片

Demo下载地址

    推荐阅读