1、scroller概念
scroller是对滑动操作的一种封装。它记录滑动过程中view应有的偏移量,但不主动作用于view。需要额外的操作将这些偏移量设置给view,从而产生滑动现象。如果不进行这些操作的话是看不到滑动现象的。
这个类有点类似 ValueAnimator 动画, 只产生动画过程中对应的值,需要我们手动将这些值设置到对应的view上从而产生动画。
2、view内容移动
上面说过scroller是辅助view进行移动内容的,那么view是如何实际移动内容的呢?通过查看官方文档及代码可以知道,view提供了两个滚动的方法,一个是scrollTo,一个是scrollBy,方法截图如下:
图一、scrollTo方法
图二、scrollBy方法
scrollTo 顾名思义,该方法是将内容移动到指定的地方,scrollTo的参数指定的绝对坐标(从参数名也可以看出端倪)。即把我移动到xx位置。 多次调用都是一样的效果。
而 scrollBy 是每次移动一定的距离,像挤牙膏一样。调用一次移动(dx,dy)距离。从源码中也可以看出是在现有的滚动量基础之上进行累加的。
但是从源码中看,并没有改变绘制的逻辑。那么是如何移动的内容的呢?原来这两个方法改变的主要是mScrollX与mScrollY这两个变量,然后触发重绘,然后在draw方法中使用这两个变量来移动画布实现内容的移动。 View的draw方法部分代码截图如下:
图三、view的绘制方法
从代码中可以看出,在确定绘制区域时使用了mScrollX和mScrollY,所以在绘制时内容实现相应的移动。 从这里也可以知道view本身并没有被移动,移动的是view的内容。
通过上面两个方法,我们就可以对view的内容进行移动了,但是如果我们要平滑移动view的内容该怎么办呢?android系统为我们提供了Scroller类来实现view平滑的移动。
3、scroller的使用
scroller有如下两个构造函数。
/**
* Create a Scroller with the default duration and interpolator.
*/
public Scroller(Context context) {
this(context, null);
}
/**
* Create a Scroller with the specified interpolator. If the interpolator is
* null, the default (viscous) interpolator will be used. "Flywheel" behavior will
* be in effect for apps targeting Honeycomb or newer.
*/
public Scroller(Context context, Interpolator interpolator) {
this(context, interpolator,
context.getApplicationInfo().targetSdkVersion >= Build.VERSION_CODES.HONEYCOMB);
}
这二者的区别在于一个提供了插值器, 一个使用了默认的。
在Scroller类中提供了两个比较重要的方法,一个是startScroll,一个是fling。方法源码如下:
图四、startScroll方法
图五、fling方法
从源码中可以看到,这两个方法主要是对一些成员变量进行赋值,如起始点x、y,终点x、y ,动画时间等等。 那Scroller是如何计算平滑过程中的中间值的呢?这就要靠Scroller类中一个比较重要的 computeScrollOffset() 方法了。
4、Scroller类的computeScrollOffset方法分析
在scroller的 computeScrollOffset 方法中,根据滚动的起始位置、速度大小、滚动时间等参数,计算出当前位置值。 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);
// mDeltaX 与 mDeltaY是要移动的距离。
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;
}
从源码中看,主要是计算出mCurrX与mCurrY这两个变量。这两个变量是根据startScroll或者fling方法给定的参数结合动画时间计算出来的。 后面View要依据这两个变量进行移动。 同时,这个方法返回了一个boolean的返回值,标识滚动是否已结束。
但是这个方法调用一次,只回计算一次当前的位置值,那么如何持续的调用并产生连续的位置值呢?有没有合适的地方持续调用呢?
在View中有一个空方法computeScroll(), 该方法在界面重绘时便会调用。故我们可以在此处调用Scroller的computeScrollOffset方法。示例如下:
@Override
public void computeScroll() {
super.computeScroll();
if (mScroller.computeScrollOffset()) {
// 如果滚动没结束,就一直调用scrollTo方法,改变view的mScrollX与mScrollY。然后在触发重绘。
// 重绘后又会调用computeScroll,直到滚动结束为止。
scrollTo(mScroller.getCurrX(),mScroller.getCurrY());
invalidate();
}
}
这个过程有点像在一个Runnable中使用Handler来post自身一样,形成一个持续的过程,直到某个条件不符合时终止。
3、fling问题
在使用startScroll方法时,我们只需要传入起始点坐标及需要滑动的距离就可以进行平滑移动了。 但是对于fling就没这么简单了。
fling方法的签名如下:
startX为开始fling时的x坐标
startY为开始fling时的y坐标
velocityX为fling时x方向上的速度
velocityY为fling时y方向上的速度
minX、maxX为fling停止时x坐标的最小值与最大值,当计算出来的最终值超出这个范围时会取这两个边界值。
minY、maxY,与x方向的最大值、最小值同理。
如上的参数中,起始坐标及最大最小值比较容易确定,但是两个速度就不太容易确定了。
fling时的速度问题可以通过如下两种方式获取。
1、velocitytractor来手动计算
2、使用GestureDectector类计算。
在使用时推荐使用第二种方式来计算,该类是android系统提供的,能提供比较好的用户体验。
4、demo演示
在自定义View中加入如下代码,便可以基本实现随手势移动和抛掷的效果了。
private void init(Context context) {
mScroller = new Scroller(context);
mGestureDetector = new GestureDetector(context, new GestureDetector.SimpleOnGestureListener() {
@Override
public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
scrollBy((int) distanceX, (int) distanceY);
return super.onScroll(e1, e2, distanceX, distanceY);
}
@Override
public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
// 这个地方的速度与实际手势操作是相反的,故取反了。
mScroller.fling(getScrollX(), getScrollY(), (int) -velocityX, (int) -velocityY, Integer.MIN_VALUE, Integer.MAX_VALUE, Integer.MIN_VALUE, Integer.MAX_VALUE);
return super.onFling(e1, e2, velocityX, velocityY);
}
@Override
public boolean onDown(MotionEvent e) {
if (!mScroller.isFinished()) {
mScroller.abortAnimation();
}
// 保证消费了down事件,后续的事件才会下发到该控件。
return true;
}
});
}
@Override
public boolean onTouchEvent(MotionEvent event) {
boolean res = mGestureDetector.onTouchEvent(event);
return res || super.onTouchEvent(event);
}
@Override
public void computeScroll() {
super.computeScroll();
if (mScroller != null && mScroller.computeScrollOffset()) {
scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
// 注意这里一定要主动调用一次invalidate。
invalidate();
}
}
5、注意事项
1、view的scrollX、scrollY的正负与实际的感官是相反的。以水平方向为例,向右滚动时,滚动量是负值。向左滚动时,滚动量是正值。
结合图二中View的绘制源码可以看到,当向右滚动时,为了显示左边的内容,left只有变小才能让canvas绘制左边的内容,所以scrollX必须为负值。同理向右、向上及向下。
2、在使用时注意, mScroller.getCurrY()及mScroller.getCurrX() 这个值在停止滑动后会保持在最终值的状态。要注意业务逻辑,是否在合理的范围内使用该值。比如,在停止滑动后仍通过该方法获取值就要注意了。
3、在一些复杂场景下,可能尝试将fling操作转化了scroll,将滚动量分成一小段一小段,然后调用scrollBy实现抛掷效果。
4、当view不可见时,调用invalidate是不会触发重绘的,故也无法调用computeScroll。