1. 简介 OverScroller
在 Android 系统中承担着为 ListView、RecyclerView、ScrollView 这些滚动控件计算实时滑动位置的任务,这些位置算法直接影响着每一次滚动的体验
众所周知,Android 的动画体验远不如 iOS,即便如今 Android 已普遍支持 120Hz 高刷,体验起来也不是非常舒服。究其原因已经不是硬件性能限制,而是其中很多动画设计本身就有问题。苹果早在很早之前就发布了 Designing Fluid Interfaces 致力于打造一个丝滑流畅的用户体验,反观 Android,对于一个日常使用中使用最多的滑动工具类 OverScroller
近几年改进竟然寥寥无几,几乎没有,实在是有点想吐槽
这个系列分为两篇,第一篇主要讲述 Android 实现滚动的核心工具类 OverScroller
的使用方法和原理,第二篇我们将探索如何进行改进,希望我们每一次探索都能给用户体验带来提升
2. 使用介绍 在使用之前,先来看看 OverScroller 能做什么:
startScroll:从指定位置滚动一段指定的距离然后停下,滚动效果与设置的滚动距离、滚动时间、插值器有关,跟离手速度没有关系 。一般用于控制 View 滚动到指定 的位置
fling:从指定位置滑动一段位置然后停下,滚动效果只与离手速度 以及滑动边界有关,不能 设置滚动距离、滚动时间和插值器。一般用于触摸抬手后继续让 View 滑动一会
springBack:从指定位置回弹到指定位置,一般用于实现拖拽后的回弹效果,不能指定回弹时间和插值器
startScroll
fling
springBack
在代码中使用也很简单:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 mOverScroller.startScroll(0 , 1600 , 0 , -1000 , 1000 ); post(new Runnable() { @Override public void run () { if (mOverScroller.computeScrollOffset()) { Log.d("OverScroller" , "x=" + mOverScroller.getCurrX() + ", y=" + mOverScroller.getCurrY()); invalidate(); if (!mOverScroller.isFinished()) { postDelayed(this , 16 ); } } } });
上面就是启动一个不断滚动并刷新 View 的最小逻辑(当然更工程化的实践也可以把 Runnable 的逻辑放在 View#computeScroll 里再通过 invalidate 触发)。fling
和 springBack
的启动方式也是一样的,这里就不再进行赘述了。
在上面代码中可知,启动一个滚动任务后,是通过不断地调用 computeScrollOffset 来计算位置的,接下来看下代码实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 public class OverScroller { public void startScroll (int startX, int startY, int dx, int dy, int duration) { mMode = SCROLL_MODE; mScrollerX.startScroll(startX, dx, duration); mScrollerY.startScroll(startY, dy, duration); } static class SplineOverScroller { void startScroll (int start, int distance, int duration) { mFinished = false ; mCurrentPosition = mStart = start; mFinal = start + distance; mStartTime = AnimationUtils.currentAnimationTimeMillis(); mDuration = duration; mDeceleration = 0.0f ; mVelocity = 0 ; } } }
startScroll 的逻辑非常的简单,只是根据参数标记了一下开始位置、结束位置、开始时间、动画时长,还没涉及位置计算(因为位置计算是放在 computeScrollOffset 的呀)
再看看定时调用的 computeScrollOffset 逻辑:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 public class OverScroller { public OverScroller (Context context, Interpolator interpolator, boolean flywheel) { if (interpolator == null ) { mInterpolator = new Scroller.ViscousFluidInterpolator(); } else { mInterpolator = interpolator; } ... } public boolean computeScrollOffset () { if (isFinished()) { return false ; } switch (mMode) { case SCROLL_MODE: long time = AnimationUtils.currentAnimationTimeMillis(); final long elapsedTime = time - mScrollerX.mStartTime; final int duration = mScrollerX.mDuration; if (elapsedTime < duration) { final float q = mInterpolator.getInterpolation(elapsedTime / (float ) duration); mScrollerX.updateScroll(q); mScrollerY.updateScroll(q); } else { abortAnimation(); } break ; break ; } return true ; } static class SplineOverScroller { void updateScroll (float q) { mCurrentPosition = mStart + Math.round(q * (mFinal - mStart)); } } }
逻辑也是很简单,实在没太多可说的……就是把插值器曲线映射到位移曲线,时长如果不指定的话,默认是 250ms,插值器需要通过构造方法传入,如果不指定的话,系统默认会指定一个 ViscousFluidInterpolator
,下面是这个插值器的曲线,可以看到是一个先缓后快再缓的动画
3.2 fling & springBack fling
和 springBack
为什么要一起说呢?因为 fling
的动画比较复杂,springBack
算是属于 fling
的其中一个子状态,考虑以下这个情况:
我们看到当以比较大的速度执行 fling 的时候,是很容易碰到边界的,fling 会根据预设的边界值执行越界并回弹,可以把整个动画过程分解成三个阶段:
SPLINE:也就是正常滑动阶段
BALLISTIC:越界减速阶段
CUBIC:回弹阶段
springBack
后执行的动画,其实就是 fling
的 CUBIC
阶段,所以干脆就放在一起说了
这个命名其实也挺有意思,这三个分别是样条曲线、弹道曲线、三次曲线,从命名上大致也可以推断出,三个阶段采用的「时间-位置」曲线是不一样的。
当然了,Android 很多控件在 fling 的时候,都把越界回弹效果取消掉,取而代之的是显示一个 EdgeEffect
。也就是说执行完 SPLINE 阶段动画后,是看不到 BALLISTIC
和 CUBIC
的,只能看到一个边缘辉光效果,列表到达顶/底部的时候,往往一下子停在那里了~
3.2.1 SPLINE 先来看看启动 fling 的入口函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 public class OverScroller { public void fling (int startX, int startY, int velocityX, int velocityY, int minX, int maxX, int minY, int maxY, int overX, int overY) { mMode = FLING_MODE; mScrollerX.fling(startX, velocityX, minX, maxX, overX); mScrollerY.fling(startY, velocityY, minY, maxY, overY); } static class SplineOverScroller { void fling (int start, int velocity, int min, int max, int over) { mOver = over; mFinished = false ; mCurrVelocity = mVelocity = velocity; mDuration = mSplineDuration = 0 ; mStartTime = AnimationUtils.currentAnimationTimeMillis(); mCurrentPosition = mStart = start; mState = SPLINE; double totalDistance = 0.0 ; if (velocity != 0 ) { mDuration = mSplineDuration = getSplineFlingDuration(velocity); totalDistance = getSplineFlingDistance(velocity); } mSplineDistance = (int ) (totalDistance * Math.signum(velocity)); mFinal = start + mSplineDistance; if (mFinal < min) { adjustDuration(mStart, mFinal, min); mFinal = min; } if (mFinal > max) { adjustDuration(mStart, mFinal, max); mFinal = max; } } private double getSplineDeceleration (int velocity) { return Math.log(INFLEXION * Math.abs(velocity) / (mFlingFriction * mPhysicalCoeff)); } private double getSplineFlingDistance (int velocity) { final double l = getSplineDeceleration(velocity); final double decelMinusOne = DECELERATION_RATE - 1.0 ; return mFlingFriction * mPhysicalCoeff * Math.exp(DECELERATION_RATE / decelMinusOne * l); } private int getSplineFlingDuration (int velocity) { final double l = getSplineDeceleration(velocity); final double decelMinusOne = DECELERATION_RATE - 1.0 ; return (int ) (1000.0 * Math.exp(l / decelMinusOne)); } } }
看代码前先来看看上面的图,图中说明了 start、min、max、over 等位置的意义,这里简要说明一下
fling 后最终停下来的位置必须在 min 和 max 区间之间
BALLISTIC 越界阶段,不能超过 over 的位置,即 over 是最大越界距离
start 可以位于 [min, max] 区间之外,如果处于区间之外,会根据速度方向和大小做一个决策,会有以下三种情况:
速度指向边界外:先执行 BALLISTIC 越界再执行 CUBIC 回弹回到边界
速度指向边界内:如果速度足以越过边界,则按照正常流程执行,先执行 SPLINE
速度指向边界内:如果速度不足以越过边界,直接执行 CUBIC 回到边界
fling 函数主要功能:
根据起始速度计算滑动的时长和距离
如果计算出最终点处于 [min, max] 区间之外,则重新计算到达边界的时长
标记当前状态为 SPLINE 状态
滑动距离和时长的计算公式中,可以把 mPhysicalCoeff 也看做常数,把时长-速度公式 和图像列出来:
$$ y=1000\cdot \exp \left( \frac{\ln \left( \frac{0.35x}{2140.47} \right)}{1.358} \right) $$
再看看距离-速度公式 :
$$ y=2140.47\cdot \exp \left( 1.74\cdot \ln \left( \frac{0.35\cdot x}{2140.47} \right) \right) $$
可以看到距离和时长都是随着速度增大而增大的,只不过时长的增长速度在后期会有一定的收敛,保证动画时长不至于太长
还有一点要注意的是,$ \frac{Distance} {Duration} $ 的比值是一个线性函数,也就是初速度越大,平均速度越大,两者是线性增长的:
$$ y=\frac{2140.47\cdot \exp \left( 1.74\cdot \ln \left( \frac{0.35\cdot x}{2140.47} \right) \right)}{1000\cdot \exp \left( \frac{\ln \left( \frac{0.35x}{2140.47} \right)}{1.358} \right)} $$
但是说实话,目前我暂时没想明白这两个公式的物理意义,有明白的大佬求告知~ 难道是利用了对数函数收敛的特性确定了时长公式,然后设定平均速度线性增长后,推导出距离公式?
目前只确定了滑动总距离和时长,那么中间过程是怎么更新位置的呢:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 public class OverScroller { public boolean computeScrollOffset () { if (isFinished()) { return false ; } switch (mMode) { case FLING_MODE: ... if (!mScrollerY.mFinished) { if (!mScrollerY.update()) { if (!mScrollerY.continueWhenFinished()) { mScrollerY.finish(); } } } break ; } return true ; } static class SplineOverScroller { boolean update () { final long time = AnimationUtils.currentAnimationTimeMillis(); final long currentTime = time - mStartTime; if (currentTime == 0 ) { return mDuration > 0 ; } if (currentTime > mDuration) { return false ; } double distance = 0.0 ; switch (mState) { case SPLINE: { final float t = (float ) currentTime / mSplineDuration; 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; } distance = distanceCoef * mSplineDistance; mCurrVelocity = velocityCoef * mSplineDistance / mSplineDuration * 1000.0f ; break ; } case BALLISTIC: { final float t = currentTime / 1000.0f ; mCurrVelocity = mVelocity + mDeceleration * t; distance = mVelocity * t + mDeceleration * t * t / 2.0f ; break ; } case CUBIC: { final float t = (float ) (currentTime) / mDuration; final float t2 = t * t; final float sign = Math.signum(mVelocity); distance = sign * mOver * (3.0f * t2 - 2.0f * t * t2); mCurrVelocity = sign * mOver * 6.0f * (- t + t2); break ; } } mCurrentPosition = mStart + (int ) Math.round(distance); return true ; } boolean continueWhenFinished () { switch (mState) { case SPLINE: if (mDuration < mSplineDuration) { mCurrentPosition = mStart = mFinal; mVelocity = (int ) mCurrVelocity; mDeceleration = getDeceleration(mVelocity); mStartTime += mDuration; onEdgeReached(); } else { return false ; } break ; case BALLISTIC: mStartTime += mDuration; startSpringback(mFinal, mStart, 0 ); break ; case CUBIC: return false ; } update(); return true ; } } }
SPLINE
阶段的位置和速度完全是由预设的 SPLINE_POSITION
样条曲线决定的,它是一个大小为 101 的数组,里面存储了一条曲线平均采样 100 次的坐标值,初始化如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 private static final int NB_SAMPLES = 100 ;private static final float [] SPLINE_POSITION = new float [NB_SAMPLES + 1 ];private static final float [] SPLINE_TIME = new float [NB_SAMPLES + 1 ];static { float x_min = 0.0f ; float y_min = 0.0f ; for (int i = 0 ; i < NB_SAMPLES; i++) { final float alpha = (float ) i / NB_SAMPLES; float x_max = 1.0f ; float x, tx, coef; while (true ) { x = x_min + (x_max - x_min) / 2.0f ; coef = 3.0f * x * (1.0f - x); tx = coef * ((1.0f - x) * P1 + x * P2) + x * x * x; if (Math.abs(tx - alpha) < 1E-5 ) break ; if (tx > alpha) x_max = x; else x_min = x; } SPLINE_POSITION[i] = coef * ((1.0f - x) * START_TENSION + x) + x * x * x; } SPLINE_POSITION[NB_SAMPLES] = SPLINE_TIME[NB_SAMPLES] = 1.0f ; }
看代码很难想象它长什么样,直接看看它的图像吧:
也就是说,SPLINE 和 startScroll 很像,位置曲线都是由一根预置的曲线决定的,把预置曲线映射真实的距离,只是 SPLINE 没有使用插值器曲线,而是使用了一根缓停的样条曲线
3.2.2 BALLISTIC SPLINE 阶段结束后,会通过 continueWhenFinished 进入下一阶段:越界阶段(前提是此时已经到达边界)。越界阶段的原理相对简单,就是一段匀减速运动(直至速度降为 0),默认加速度 a 为 -2000.0f,到达边界进入 BALLISTIC 阶段的初始化逻辑在 onEdgeReached
中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 private void onEdgeReached () { final float velocitySquared = (float ) mVelocity * mVelocity; float distance = velocitySquared / (2.0f * Math.abs(mDeceleration)); final float sign = Math.signum(mVelocity); if (distance > mOver) { mDeceleration = - sign * velocitySquared / (2.0f * mOver); distance = mOver; } mOver = (int ) distance; mState = BALLISTIC; mFinal = mStart + (int ) (mVelocity > 0 ? distance : -distance); mDuration = - (int ) (1000.0f * mVelocity / mDeceleration); }
速度: $$ v_t=v_0+at $$
距离: $$ s_t=v_0t + \frac{at^2} {2} $$
这里有个小吐槽,加速度固定是 2000,这也太小了吧,也就是说,如果越界速度为 10000, 那么需要 5s 之后,速度才能降为 0,一个 5s 的动画童鞋们估计都知道意味着多久吧?
3.2.3 CUBIC 上一节内容知道,BALLISTIC 阶段结束时,速度已经降低为 0,我们终于来到最后一段, 从 continueWhenFinished 里会调用 startSpringback
作为 CUBIC 的初始化:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 private void startSpringback (int start, int end, int velocity) { mFinished = false ; mState = CUBIC; mCurrentPosition = mStart = start; mFinal = end; final int delta = start - end; mDeceleration = getDeceleration(delta); mVelocity = -delta; mOver = Math.abs(delta); mDuration = (int ) (1000.0 * Math.sqrt(-2.0 * delta / mDeceleration)); }
时长计算依然使用匀加速直线运动的逻辑,想象一段初速度为 0,加速度为 a, 距离为 delta 的情况:
$$ delta=\frac{(v_0 + v_t)} {2} t $$
则: $ v_0=0,v_t=at $,那么 $ delta=\frac{at^2} {2} $,$ t=\sqrt{ \frac{2*delta} {a}}$
在 update
方法中,更新 CUBIC 的核心逻辑是:
1 2 3 4 5 6 7 8 9 10 11 case CUBIC: { final float t = (float ) (currentTime) / mDuration; final float t2 = t * t; final float sign = Math.signum(mVelocity); distance = sign * mOver * (3.0f * t2 - 2.0f * t * t2); mCurrVelocity = sign * mOver * 6.0f * (- t + t2); break ; }
核心逻辑是这个 3.0f * t2 - 2.0f * t * t2
, 这其实是一个比较常用的三次曲线:
在 [0, 1] 区间内,是一个缓入缓出的曲线。至此,CUBIC 的运动规律也摸清楚了,在固定时间内,把时间映射到 [0, 1] 的区间,再把 y 坐标映射实际的位置
4. 小结 目前为止,我们终于把 startScroll
和 fling
各阶段的曲线看了一遍,至于 springBack
和其他一些情况都大同小异,就不细述了。
很多时候 OverScroller 都是只是使用的固定的曲线映射真正的曲线,比如 startScroll
、SPLINE
和 CUBIC
,那如果想改变效果的话,是不是修改一下曲线形态就可以了呢?但一条曲线是否真的能在不同速度下都有比较好的表现吗?或许我们还要有很多的实践和尝试才能做出一段让用户舒服滑动,而这些探索和尝试,将放在下一篇文章中详细讨论~