前言:关于Scroller其实在上面一篇博客有提过,大致的说了下使用流程,没有深入细节研究,这篇博客将会带你从源码的角度结合案例来深入了解它。
Scroller是Android开发中用来辅助处理弹性滑动的一个类,啥叫弹性滑动呢?让你的View在滑动过程中有个平缓的过程,而不是生硬的滑过去。增加用户体验效果,这就是Scroller。从本身来说没有什么意义,单独也不能使用,需要配合View的相关方法才能发挥其作用,Android之中像可以滑动的ListView,GridView,ViewPager等其实底层都使用了Scroller,由此可见Scroller的作用还是很大的,那么了解Scroller的底层原理是一个高级Android开发工程师所必备掌握的,了解其底层原理,我们也可以写出一个简易版的ViewPager,说的滑动大家应该明白在Android中所有的View都是可以滑动的,因为所有的View都有scrollTo和scrollBy两个用来滑动的方法,关于这两个方法的使用和区别这里就不在多说,可参考前一篇博客,scrollTo是相对于目标位置的绝对滑动,scrollBy是相对于当前位置的相对滑动,scrollBy底层其实就调用了scrollTo方法。
下面我们首先来看段代码,使用弹性滑动,将一个View滑动到指定的位置。
private Scroller mScroller = new Scroller(mContext);
//将一个View缓慢的滚动到指定位置
private void smoothScrollTo(int x , int y){
int scrollX = getScrollX();
int deltaX = x - scrollX;
//在一秒内将View滑动了deltaX个像素
mScroller.startScroll(scrollX, 0, deltaX, 0,1000);
invalidate();
}
@Override
public void computeScroll() {
if(mScroller.computeScrollOffset()){
scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
postInvalidate();
}
}
是不是发现很简单,其实关于Scroller的使用流程是很固定的,具体会在下面进行分析,上面是一个简单的操作,接下来我们开始分析工作流程。
首先当我们实例化一个Scroller的时候,它内部什么也没做,只是完成了一些变量的初始化,源码如下:
/**
* Create a Scroller with the default duration and interpolator.
*/
public Scroller(Context context) {
this(context, null);
}
接着当我们调用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代表滑动开始时X轴的坐标和Y轴的坐标,也就是滑动的起点。dX和dY代表X轴和Y轴要滑动的距离(需要注意,这里如果往右滑为负,往左滑为正,往上滑为正,往下滑为负)。
duration代表滑动所持续的时间,单位为毫秒。到这里大家应该明白光靠调用startScroll这个方法是根本不能让View进行滑动的,那么View到底是怎么滑动的呢?答案就是下面的invalidate方法,没错,就是它,那么问题来了它又是怎么让View进行滑动的呢,我们知道调用invalidate方法会让View进行重绘的,你已经知道View进行重绘的时候必定会调用View的draw方法,draw方法最后会走到dispatchDraw方法,源码如下:
protected void dispatchDraw(Canvas canvas) {
.
.
.
.
if ((flags & FLAG_USE_CHILD_DRAWING_ORDER) == 0) {
for (int i = 0; i < count; i++) {
final View child = children[i];
if ((child.mViewFlags & VISIBILITY_MASK) == VISIBLE || child.getAnimation() != null) {
more |= drawChild(canvas, child, drawingTime);
}
}
} else {
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);
}
}
}
}
drawChild方法如下:
protected boolean drawChild(Canvas canvas, View child, long drawingTime) {
.
.
.
.
if (!concatMatrix && canvas.quickReject(cl, ct, cr, cb, Canvas.EdgeType.BW) &&
(child.mPrivateFlags & DRAW_ANIMATION) == 0) {
return more;
}
child.computeScroll();
final int sx = child.mScrollX;
final int sy = child.mScrollY;
}
可以发现只要我们进行View的invalidate重绘,方法最后必定会都到View的computeScroll方法,还记得我们在上面那段代码处理View滑动时候覆写的computeScroll方法吧,到这里你应该明白这是由于这个computeScroll方法View才能实现弹性滑动,主要流程是这样的,我再完整叙述一遍:当我们调用View的invalidate进行视图重绘的时候会调用View的draw方法,在draw方法底层会调用dispatchDraw方法,而又在dispatchDraw方法底层最终调用computeScroll方法,在这个方法中又会去向Scroller对象获取当前的scrollX和scrollY,然后通过scrollTo方法来进行滑动,接着又调用postInvalidate方法来进行二次重绘,然后又继续向Scroller对象获取当前的scrollX和scrollY,并通过scrollTo方法来滑动到指定位置,如此反复,直至整个平滑过程结束。
最后我们来看下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;
}
代码不多,我们来分析下,首先看第二句代码
if (mFinished) {
return false;
}
如果mFinished为真则直接return false结束,下面不在执行。如果mFinished为假则跳过执行下面的代码,可以发现mFinished默认是false的,还记得在我们进行滑动的时候一开始调用的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;
}
很显然到这里你应该明白了,mFinished 默认为false,代码继续往下执行,在第六行我们可以看到算出来一个时间差,单位是毫秒,AnimationUtils.currentAnimationTimeMillis()获取的是当前的时间(注意这里的时间指的是从开机到现在的时间),mStartTime 为程序刚刚调用startScroll方法的时候的时间毫秒值,接着看第八行代码,到这里你应该明白在你不断的进行View视图重绘的情况下,这里的timePassed会随着时间的流逝来渐渐增加一直到我们动画持续的时间,接着往下,mMode其实就是SCROLL_MODE,然后下面代码大致的意思就是根据时间的流逝算出来一个百分比,接着下面两句代码:
mCurrX = mStartX + Math.round(x * mDeltaX);
mCurrY = mStartY + Math.round(x * mDeltaY);
不断的给mCurrX和mCurrY进行赋值,到这里你是不是焕然大悟?mCurrX和mCurrY正是我们前面通过Scroller获取的getmCurrX()和getmCurrY(),mStartX和mStartY是我们刚开始时候的起始位置,每次小幅度的加上移动的小距离,最终又通过scrollTo来滑动到此位置,n此小幅度的滑动最终就形成了平滑的效果,即弹性滑动。
可以发现只要动画没有结束那么就一直会返回true,到动画结束了的时候会执行:
mCurrX = mFinalX;
mCurrY = mFinalY;
mFinished = true;
也就是会给mFinished = true;最后在上面直接返回了false,代表动画结束。也可以通过Scroller.isFinished()返回true来判断动画滑动结束。
至此,关于Scroller你所了解的一切就结束了。如有疑问可以在下方留言。接下来 我们通过一个案例来熟练下Scroller,我选用了仿微信右滑消失的功能来讲解下,网上代码很多,主要还是理解Scroller用法。新建SwipeBackLayout继承自FrameLayout代码如下:
/**
*
* @author xyy 仿微信右滑消失
*
*/
public class SwipeBackLayout extends FrameLayout {
private static final String TAG = SwipeBackLayout.class.getSimpleName();
/** 当前的View */
private View mContentView;
/** 滑动的最小距离 */
private int mTouchSlop;
/** 按下时相对屏幕的X轴坐标 */
private int downX;
/** 按下时相对屏幕的Y轴坐标 */
private int downY;
/** 用来记录X轴坐标的临时变量 */
private int tempX;
/** 处理弹性滑动的辅助类 */
private Scroller mScroller;
/** 当前布局的宽度 */
private int viewWidth;
/** 用来记录是否滑动 */
private boolean isSilding;
/** 用来记录当前Activity书否finish */
private boolean isFinish;
/** 给当前页面绘制的黑色的阴影效果 */
private Drawable mShadowDrawable;
/** 用来记录当前的Activity */
private Activity mActivity;
/** 存放ViewPager */
private List mViewPagers = new LinkedList();
public SwipeBackLayout(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public SwipeBackLayout(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
mTouchSlop = ViewConfiguration.get(context).getScaledTouchSlop();
mScroller = new Scroller(context);
mShadowDrawable = getResources().getDrawable(R.drawable.shadow_left);
}
/**
*
* @param activity
*/
public void attachToActivity(Activity activity) {
mActivity = activity;
TypedArray a = activity.getTheme().obtainStyledAttributes(
new int[] { android.R.attr.windowBackground });
/** 获取typedArray数组中指定位置的资源id值 */
int background = a.getResourceId(0, 0);
a.recycle();
/** 获取Activity布局的顶层视图decorView */
ViewGroup decor = (ViewGroup) activity.getWindow().getDecorView();
/** 获取decorView的孩子 */
ViewGroup decorChild = (ViewGroup) decor.getChildAt(0);
/** background就是上面获得的android.R.attr.windowBackground */
decorChild.setBackgroundResource(background);
/** 干掉当前decorView的孩子 */
decor.removeView(decorChild);
/** 将当前的decorChild添加到自定义的FrameLayout中 */
addView(decorChild);
/** 获取decorChild的父View */
setContentView(decorChild);
/** 将当前的SwipeBackLayout添加到顶层decorView的布局之中 */
decor.addView(this);
}
private void setContentView(View decorChild) {
mContentView = (View) decorChild.getParent();
}
/**
* 事件拦截操作
*/
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
// 处理ViewPager冲突问题
ViewPager mViewPager = getTouchViewPager(mViewPagers, ev);
Log.i(TAG, "mViewPager = " + mViewPager);
// 如果当前在页面滑动的是ViewPager并且viewPager.getCurrentItem()不等于0则不拦截事件
if (mViewPager != null && mViewPager.getCurrentItem() != 0) {
return super.onInterceptTouchEvent(ev);
}
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
downX = tempX = (int) ev.getRawX();
downY = (int) ev.getRawY();
break;
case MotionEvent.ACTION_MOVE:
int moveX = (int) ev.getRawX();
// 按下的坐标小于screenWidth并且X轴滑动的偏移量大于mTouchSlop并且Y轴的偏移量小于mTouchSlop,则拦截事件
if (moveX - downX > mTouchSlop
&& Math.abs((int) ev.getRawY() - downY) < mTouchSlop) {
return true;
}
break;
}
return super.onInterceptTouchEvent(ev);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_MOVE:
int moveX = (int) event.getRawX();
int deltaX = tempX - moveX;
tempX = moveX;
if (moveX - downX > mTouchSlop
&& Math.abs((int) event.getRawY() - downY) < mTouchSlop) {
isSilding = true;
}
// 右滑并且按下坐标小于screenWidth
if (moveX - downX >= 0 && isSilding) {
// 让当前View开始沿着X轴滚动,Y轴不变,右滑为负
mContentView.scrollBy(deltaX, 0);
}
break;
case MotionEvent.ACTION_UP:
isSilding = false;
// 当前View在X轴滚动的距离大于当前View一半的时候此时up(右滑为负,左滑为正)
if (mContentView.getScrollX() <= -viewWidth / 2) {
isFinish = true;
// 销毁Activity
finishPage();
} else {
// 滚动到起始位置
resetPage();
isFinish = false;
}
break;
}
return true;
}
/**
* 获取SwipeBackLayout里面的ViewPager的集合
*
* @param mViewPagers
* @param parent
*/
private void getAlLViewPager(List mViewPagers, ViewGroup parent) {
int childCount = parent.getChildCount();
for (int i = 0; i < childCount; i++) {
View child = parent.getChildAt(i);
if (child instanceof ViewPager) {
mViewPagers.add((ViewPager) child);
} else if (child instanceof ViewGroup) {
getAlLViewPager(mViewPagers, (ViewGroup) child);
}
}
}
/**
* 返回我们touch的ViewPager
*
* @param mViewPagers
* @param ev
* @return
*/
private ViewPager getTouchViewPager(List mViewPagers,
MotionEvent ev) {
if (mViewPagers == null || mViewPagers.size() == 0) {
return null;
}
Rect mRect = new Rect();
for (ViewPager v : mViewPagers) {
v.getHitRect(mRect);
if (mRect.contains((int) ev.getX(), (int) ev.getY())) {
return v;
}
}
return null;
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
super.onLayout(changed, l, t, r, b);
if (changed) {
viewWidth = this.getWidth();
getAlLViewPager(mViewPagers, this);
Log.i(TAG, "ViewPager size = " + mViewPagers.size());
}
}
@Override
protected void dispatchDraw(Canvas canvas) {
super.dispatchDraw(canvas);
if (mShadowDrawable != null && mContentView != null) {
int left = mContentView.getLeft()
- mShadowDrawable.getIntrinsicWidth();
int right = left + mShadowDrawable.getIntrinsicWidth();
int top = mContentView.getTop();
int bottom = mContentView.getBottom();
mShadowDrawable.setBounds(left, top, right, bottom);
mShadowDrawable.draw(canvas);
}
}
/**
* 销毁页面
*/
private void finishPage() {
// delta为此时手指up的时候距离右边缘的距离
final int delta = (viewWidth + mContentView.getScrollX());
// 调用startScroll方法来设置一些滚动的参数,实现手指up时候的弹性滑动,必须在computeScroll()方法中调用scrollTo来滚动item
mScroller.startScroll(mContentView.getScrollX(), 0, -delta + 1, 0,
Math.abs(delta));
// 视图重绘
postInvalidate();
}
/**
* 滚动到起始位置
*/
private void resetPage() {
int delta = mContentView.getScrollX();
mScroller.startScroll(mContentView.getScrollX(), 0, -delta, 0,
Math.abs(delta));
postInvalidate();
}
@Override
public void computeScroll() {
if (mScroller.computeScrollOffset()) {
mContentView.scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
postInvalidate();
// 返回true代表滑动已经结束
if (mScroller.isFinished() && isFinish) {
mActivity.finish();
}
}
}
}
效果如下:
代码很简单,这里讲解下实现过程,大家应该知道Activity布局的根视图叫做DecorView,它是一个帧布局。我们在Activity中加载的布局activity_main.xml其实是一个叫id=content的帧布局的孩子,这里简单的画了一张图,通过这张图将会很清晰布局之中的结构。
我们自定义了一个SwipeBackLayout继承自FrameLayout,在代码的61行我们获取了关于Android系统所能识别的滑动最小距离TouchSlop,以及实例化一个Scroller。mShadowDrawable为一张9patch图,用于仿微信给Activity左侧页面绘制阴影效果。
接着在75行的attachToActivity方法中我们首先获取了根视图decorView,接着获取了它的孩子decorChild,这时候通过根视图decorView把当前decorChild这个孩子给remove掉了,然后又通过我们自定义的SwipeBackLayout帧布局将decorChild作为SwipeBackLayout孩子给添加进去了,最后整体作为孩子挂到decorView之中。
这时候的结构如下面这张图:
是不是很清晰,从图中我们可以发现这时候我们的SwipeBackLayout是包含在Activity的布局之上的。接下来就简单了,这里利用了事件分发机制,我们知道事件最先是由Activity传递到Window,接着到decorView根视图,然后层级传替到我们的SwipeBackLayout上,我们在onInterceptTouchEvent方法中判断手指的滑动操作,并且当滑动的偏移量大于我们的TouchSlope我们就认为发生了滑动操作,这时候将事件拦截掉,交由当前SwipeBackLayout的onTouchEvent方法处理,可以发现我们在onTouchEvent的move方法中再次进行判断,当手指滑动的偏移量大于TouchSlope并且右滑操作我们就调用了mContentView.scrollBy(deltaX, 0);mContentView为上方decorChild的父亲,也就是当前的SwipeBackLayout,deltaX为滑动的偏移量(右滑为负,左滑为正)这时候直接根据偏移量去做响应的滑动即可,最后在up的时候我们判断了当前滑动的距离是否大于屏幕的一半,mContentView.getScrollX() <= -viewWidth / 2,如果大于一半,则销毁Activity也就是执行finishPage()方法,代码如下:
/**
* 销毁页面
*/
private void finishPage() {
// delta为此时手指up的时候距离右边缘的距离
final int delta = (viewWidth + mContentView.getScrollX());
// 调用startScroll方法来设置一些滚动的参数,实现手指up时候的弹性滑动,必须在computeScroll()方法中调用scrollTo来滚动item
mScroller.startScroll(mContentView.getScrollX(), 0, -delta + 1, 0,
Math.abs(delta));
// 视图重绘
postInvalidate();
}
如果小于屏幕的一半,此时up应该重置的开始位置,也就是执行resetPage方法,代码如下:
/**
* 滚动到起始位置
*/
private void resetPage() {
int delta = mContentView.getScrollX();
mScroller.startScroll(mContentView.getScrollX(), 0, -delta, 0,
Math.abs(delta));
postInvalidate();
}
到这里,可以发现我们的Scroller终于上场了,当滑动的距离大于屏幕一半的时候我们调用了mScroller.startScroll让View开始平滑滚动,首先第一个参数为滚动开始时的X轴位置,其实也就是mContentView.getScrollX()X轴的偏移量,第二个参数为Y轴开始的位置,Y轴我们不需要变化,所以直接0即可,第三个参数为X轴滚动的距离,final int delta = (viewWidth + mContentView.getScrollX());viewWidth 为当前View的宽度加上X轴此时的偏移量(为负)此时相加得出的结果正好是要滚动的距离,最后一个参数书滚动的时候,到这里我们应该明白此时不能让View滚动的(在上面已经说过)真正滚动的是下面的postInvalidate方法,这时候会调用View的computeScroll方法,代码如下:
@Override
public void computeScroll() {
if (mScroller.computeScrollOffset()) {
mContentView.scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
postInvalidate();
// 返回true代表滑动已经结束
if (mScroller.isFinished() && isFinish) {
mActivity.finish();
}
}
}
可以发现和最上面分析Scroller的时候代码几乎一致,没错,关于Scroller我们所使用的就这几个套路,代码格式很固定。在这个方法中通过mScroller.computeScrollOffset()来判断动画有没有结束,没有结束将返回true,接着继续让mContentView.scrollTo进行小幅度的移动,然后再一次的invalidate进行视图重绘,最后通过mScroller.isFinished()返回true代表动画结束,在上面我们查看源码已经知道这里不再多说,最后将Activityfinish掉。反之,则重置,滚动到起始位置,这里不再分析。到此整个Scroller案例加源码分析也就结束了。