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 触发)。flingspringBack 的启动方式也是一样的,这里就不再进行赘述了。

在上面代码中可知,启动一个滚动任务后,是通过不断地调用 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,下面是这个插值器的曲线,可以看到是一个先缓后快再缓的动画

ViscousFluidInterpolator

3.2 fling & springBack

flingspringBack 为什么要一起说呢?因为 fling 的动画比较复杂,springBack 算是属于 fling 的其中一个子状态,考虑以下这个情况:

我们看到当以比较大的速度执行 fling 的时候,是很容易碰到边界的,fling 会根据预设的边界值执行越界并回弹,可以把整个动画过程分解成三个阶段:

  • SPLINE:也就是正常滑动阶段
  • BALLISTIC:越界减速阶段
  • CUBIC:回弹阶段

springBack 后执行的动画,其实就是 flingCUBIC 阶段,所以干脆就放在一起说了

这个命名其实也挺有意思,这三个分别是样条曲线、弹道曲线、三次曲线,从命名上大致也可以推断出,三个阶段采用的「时间-位置」曲线是不一样的。

当然了,Android 很多控件在 fling 的时候,都把越界回弹效果取消掉,取而代之的是显示一个 EdgeEffect。也就是说执行完 SPLINE 阶段动画后,是看不到 BALLISTICCUBIC 的,只能看到一个边缘辉光效果,列表到达顶/底部的时候,往往一下子停在那里了~

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)
$$

2021-04-12-10-26-21

再看看距离-速度公式

$$
y=2140.47\cdot \exp \left( 1.74\cdot \ln \left( \frac{0.35\cdot x}{2140.47} \right) \right)
$$
2021-04-12-10-30-53

可以看到距离和时长都是随着速度增大而增大的,只不过时长的增长速度在后期会有一定的收敛,保证动画时长不至于太长

还有一点要注意的是,$ \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)}
$$

2021-04-12-10-45-06

但是说实话,目前我暂时没想明白这两个公式的物理意义,有明白的大佬求告知~ 难道是利用了对数函数收敛的特性确定了时长公式,然后设定平均速度线性增长后,推导出距离公式?

目前只确定了滑动总距离和时长,那么中间过程是怎么更新位置的呢:

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

看代码很难想象它长什么样,直接看看它的图像吧:

2021-04-12-11-24-04

也就是说,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, 这其实是一个比较常用的三次曲线:

2021-04-12-14-34-02

在 [0, 1] 区间内,是一个缓入缓出的曲线。至此,CUBIC 的运动规律也摸清楚了,在固定时间内,把时间映射到 [0, 1] 的区间,再把 y 坐标映射实际的位置

4. 小结

目前为止,我们终于把 startScrollfling 各阶段的曲线看了一遍,至于 springBack 和其他一些情况都大同小异,就不细述了。

很多时候 OverScroller 都是只是使用的固定的曲线映射真正的曲线,比如 startScrollSPLINECUBIC,那如果想改变效果的话,是不是修改一下曲线形态就可以了呢?但一条曲线是否真的能在不同速度下都有比较好的表现吗?或许我们还要有很多的实践和尝试才能做出一段让用户舒服滑动,而这些探索和尝试,将放在下一篇文章中详细讨论~