iOS的UIScrollView交互特性

2,411 阅读16分钟

介绍

这篇文章深入研究了一下UIScrollView中涉及到的一些交互特点,并给出了实现一个自定义ScrollView的方法和Demo。以防我们被问到诸如“如何实现一个自定义UIScrollView”这样的问题有时会摸不着头脑,不知道提问者想考什么。即便你有十足的把握完美回答这个问题,也不妨看一遍本文,这里的结论可能会帮助你补充一些细节。

Demo:

这里提供了Demo,来更好的表达一些必要的内容:LNCustomScrollView

这个Demo没有提供Pod支持,只做学习使用。

contentOffset的本质

(Bounds.x,bounds.y)标记了一个UIView的所有子元素依赖的参考系原点,如果这个点被标记被{-100, -100.f},那么这个视图的所有子视图都会基于(-100, -100)这个点开始绘制。例如,这种情况下,一个frame = {20.f,20.f,100.f,100.f} 的子视图 会从(-80.f, -80.f)开始绘制,所以,你只能看到这个子元素右下角的一小部分;在UIScrollView中,bounds.x 和 bounds.y 被独立出来,叫做contentOffset,只要不断变更contentOffset(bounds),就能做出可滚动的效果。

射击很容易,但瞄准很难”,实现一个UIScrollView并不只是setContentOffset这样简单的事,如果我们稍加思考,就会意识到设置成多少、什么时间设置这种问题并不是立刻就能想出来的。

我们总结一下在setContentOffset可能需要考虑的繁琐问题:

  1. panGesture生效的时候,这个时候UIScrollView通常要移动相同的位置和方向。
  2. panGesture结束后,有速度依然生效,在之后的一段时间里要持续进行减速。
  3. panGesture结束后,contentOffset超出了ContentSize,在之后的一段时间里需要恢复到非拉伸状态。
  4. (2)和(3)结合起来,panGesture结束后,有速度依然生效,在之后的一段时间内减速,减速到一半时突然触及了边界,开始进行bounces,最后回弹到非拉伸状态。
  5. 在set时保证x、y两个方向上的运动独立运行。
  6. 在超过边界进行panGestures时,pan转化为contentOffset的比率较小,而未超过边界时,比率为1:1。

如果我们只在panGesture生效期间setContentOffset,这样做出来的效果和在屏幕上画一个可以跟手的View别无二致,这种不能称之为“ScrollView”,而更像是"followView"。

为了搞清楚这些问题,=在写自己的ScrollView之前,需要研究一下原生的UIScrollView是如何工作的;即便使用UIDynamic 或 POP 来实现,有些问题依然需要研究,例如:我们如何保证在放一个Spring在边界时不发生简谐振动?又如何保证不论以大的力还是小的力冲击到边界时都不会荡来荡去,而当我们真正将这些研究透彻之后,自己做动画比使用库达到的效果更理想,因为过于真实的物理引擎会让一些交互变得又臭又长,我们需要适时取舍来达到更佳的手感。

UIScrollView中的运动规律

UIScrollView涉及到的运动规律有两个:

  • decelerate:手指拖动UIScrollView后,UIScrollView自动滚动的减速过程。
  • bounces:UIScrollView触碰边界后的回弹。 (还有一个是pan手势的边界位移转化率,这个在Demo中有所实现,但在这里暂时不讨论)。

Decelerate:

现象

我们可以通过ScrollViewDidScroll代理不断打印出UIScrollView的contentOffset,来追踪这个过程,在EndDragging开始记录,EndDecelerate结束记录。但UIScrollView给出了一个更直接的代理来获取这些信息:

- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset
{
	NSLog(@"减速速度统计:%lf", velocity.y);
	NSLog(@"减速距离统计:%lf", targetContentOffset->y - scrollView.contentOffset.y);
}

你可以不断尝试滚动一个UIScrollView,并打印相同方向上的velocity和 (targetOffset - currentOffset),在无限边界的情况下,这个代理会给出pan手势结束后的滚动速度和减速结束后预测的位置,像上面这样。大部分情况下你会的到类似这样的一组结果(v是速度,y是从这个速度开始减速知道停止移动的距离):

  • v: 5.0270956 y: 2506.5
  • v: 1.802126 y: 895.0
  • v: 1.412374 y: 700.5
  • v: 1.687861 y: 838.0 观察一下,发现这些数值大概满足这个算式:
(v * 1000.f)/2.f ~= y

在Demo中,有个DecelerateObservation,已经写好了这些,可以下载Demo并尝试在不碰撞到边界的时候观察控制台打印出的数值。为了方便感受到ScrollView的运动,我用相等大小格子的UICollectionView替代UIScrollView,这不会影响结果,UICollectionView就是UIScrollView的子类。

现象分析

我们需要寻找一种满足如下关系的减速运动:从开始减速到减速结束运动的总距离总是减速起始速度的一半。 (先忽略这个1000,它可能只是计算单位的差别,类似 pt/s 或 pt/ms)

反过来理解一下这个运动会比较方便:一个物体从静止开始加速到任意的v时,他们移动的距离y总是那一时刻速度的一半。所以,有这些推导:

∵ v/2 = y,
∴ (v1 - 0)/2 = y1 - 0 , (v2 - 0)/2 = y2 - 0,
∴ (v1 - v2)/2 = y1 - y2,
Δv = 2*Δy,
dv/dt = 2 dy/dt,
∴ a = 2v

当v1和v2的时间间隔无限小的时候(= dt)就变成了:a = 2v

这个意思是:速度越大,阻力越大,那么这个运动实际上就是我们通常所说的阻尼运动 或 低速状态下的空气阻力。

根据a、v关系可以继续推导一下 v、t关系:

dv/dt = - 2 * v
1/(2*v) dv = dt , 两边积分
(1/2) * ln(v) = - t + C,
t = 0 时 v = v0, 得:
v = v0 * e^(-2*t)

指数上面的2就是我们之前观察到的那个v、y的2倍关系,这个数越大减速到0需要的距离越短,也就意味着减得更狠;这个数就是我们常说的阻尼系数。

结论

所以:UIScrollView的减速运动就是阻尼系数为2的阻尼运动, 减速持续的距离总是初始速度的一半。

  • Q:y与v的倍数关系为什么是约等于?
  • A:这个不是误差,应该是Apple有意为之,因为阻尼减速是指数衰减,所以无限趋近于0的过程是非常漫长的,如果你看v/t的关系会发现,即使是t无限大,v都不会减为0,因为指数总是正的,所以为省略后面那段很长很慢的减速,Apple就把它截断了。 这个截断量大概是 v = (12 ~ 13)pt/s , 小于这个速度就会被记为”不动了“,所以y总是比v/2小一点(6左右)。

Bounces:

Bounces动画不好猜测,但我们仍需认为它与弹簧是相关的,在UIScrollView依靠惯性冲击边界时是在压缩弹簧,恢复到正常状态时将弹簧从压缩状态恢复到拉伸状态,区别是弹簧不会发生简谐振动。

第一个猜想:

弹簧确实发生了简谐振动,但只完成了正弦曲线的前半个周期,在此之后的振动在offset回归到0时就被强制取消了。 我们通过记录一个UIScrollView依赖惯性冲撞到offset == 0的时间 和 在本次冲撞后回弹到offset == 0点 的时间,取二者的差验证这个猜想的正确性。 我们以不同冲撞速度进行两次实验,如果这个猜想正确,两次冲撞得到的时间差应当是一致的。 弹簧简谐振动的周期是:T = 2 * PI * sqrt(k/m) 在弹簧的k和物体质量都不变的情况下,这个周期是一致的,显然冲撞的力度不会改变这两个客观事物的属性。

很遗憾,使用更大的惯性冲撞边界的回弹时间总是大于使用较小惯性的回弹时间;那么这个猜想就被证实是错误的了。

第二个猜想:

除了弹簧的力之外,还有额外的某些力也作用在了这个虚拟的物体上,让这个运动的前半程看起来像正弦一样,后半程又看起来像指数一样,这个结果应该就接近了。

有了上面decelerate的经验,我们十分清楚给一个运动曲线增加指数衰减特性的方法就是加一个阻尼。所以,我们尝试为这个简谐运动增加一个额外阻尼,那么UIScrollView冲击边界时的运动方程变成:

k * y - C * v = m * a
k:劲度系数
C:阻尼系数 
m:质量
a:加速度
y:距离弹簧平衡点的距离

对这个方程稍作化简:

a + (C/m)*v + (k/m)*y = 0

我们通常认为v是y对时间的求导,a是v对时间的求导,也就是y的二阶导,所以这个算式变成:

y'' + (C/m)*y' + (k/m) * y = 0

C、m、k均为常数,我们做如下替换以方便计算,令:

(C/m)/2 = δ; sqrt(k/m) = ω;

这个方程变成:

y'' + 2*δ*y' + ω^2 = 0

特征根方程:

λ^2 + 2*δ*λ + ω^2 = 0;
Δ = 4*δ^2 - 4*ω^2;

情况1:Δ > 0

一对相异实根,需要 δ > ω ,(阻尼相对弹性较大) 过阻尼 程序员的直觉告诉我苹果不会用过阻尼运动影响用户手感,如果可以,苹果一定不会让UIScrollView慢悠悠地回复到本来的状态。

情况2:Δ < 0

一对共轭复根,需要 δ > ω ,(阻尼相对弹性较小) 弱阻尼,得到的根是这种形式:e^(αt) * (cos(βt) + sin(β*t)) 画出来是指数包络的弦类曲线。 振幅会不断减小,但周期不变。

情况3:Δ = 0

一对相等实根,需要 δ = ω 临界阻尼 哦!我的上帝,瞧瞧这优秀的名字! 这种阻尼可以让UIScrollView以最快的速度回复到平衡状态,又不至于在那里荡来荡去;临界阻尼被广泛应用在各种减震系统中。

临界阻尼条件:ω = δ;此时,齐次方程通解: y = (C1 + C2t)e^(δt); 借助一些条件省去一些参数:阻尼震动开始时,我们认为是offset.y 刚开始变负的时候,所以:t = 0 时, y = 0; 得到 C1 = 0;这个关系变成了:

y = A * t * e ^ (-δ*t);

(C2换成了A,也是常数)

需要额外确定的两个参数: A 和 δ , δ 来源于 阻尼系数,所以δ是个定值。 把C2换成A可以让这个常数看起来更像是”振幅“或是与之类似的某些参数,显然A是与本次冲撞的力度相关的:撞得越狠,这个函数能达到的最大值也就越大,A越大。

参数观测方法:

由于存在A、δ两个待定系数,而我们能观测到的数值主要是contentOffset,也就是位移,A、δ 的影响因素显然是速度、加速度这种更高级别的参数,通过观察位移确定A、δ难度较高,所以在这里我们使用一种简单的优化方法来让这条曲线与实际值不断趋近。

梯度下降法思路:

  1. 给定一组UIScrollView冲击边界触发Bounces的真实数据集合S,S内包含Bounces动画期间内所有的contentOffset取值:[y0,y1, y2, y3...]。
  2. 给定一组待定系数的解(A、δ、φ),通过方程y = A * (t + φ) * e ^ (-δ*(t + φ)) 计算出所有的理论值[y0',y1', y2', y3'...]。
  3. 计算出理论值与实际值的方差(这个方差函数实际上就是我们要优化的目标函数),sum = (y0 - y0')^2 + (y1 - y1')^2 + (y2 - y2')^2 + (y3 - y3')^2...
  4. 如果我让A、δ、φ中任意一个值,单独进行变化(例如:A + ΔA / δ + Δδ / φ + Δφ ),能够让目标函数的值变小,那么就对这个变化予以肯定,将原值修改为变化后的值。
  5. 对于三个待定系数,我们有六个方向进行变化,A+、A-、δ+、δ-、φ+、φ-,当这六中变化中有多种变化都可以让目标函数结果变小,那么我们取变得最小的那个变化,因此这个方法也称为:最速下降法。
  6. 当目标函数被优化到0时,说明我们的目标曲线与实际观测出的曲线已经完全重合了,我们机器上观测到的数值总是离散的,所以我们认为这个数值被优化到个位数时就已经比较接近了。

在Demo中,我们给出了一个OptimizationDemo,用来展示这个过程,这个Demo中给出了三组我自己从BouncesOptimization中截取的Bounces数据放在了三个数组中,当你点击顶部的Collection的“数据1”、“数据2”时会选择一组数据,并在屏幕上绘制这组数据的Bounces曲线(黑色);当你再点击start时,一个Trainer会将(A、δ、φ)从(0.f、0.f、0.f)开始优化,同时在屏幕上不断绘制一条优化曲线(红色)。最终这两条曲线会重合在一起,并输出最终的(A、δ、φ) 和 sum在本次优化结束后的最优值。

在进行多次优化后,我们会发现δ值总是固定的,在10.8~10.9之间;所以这个值就被确定下来了,这个优化过程表现为:

cMfdpt.gif

φ的值可以忽略,我们给出的公式:y = A * t * e ^ (-δ*t) 总是认为这个运动开始时t = 0,但我们实际取到Bounces的时候t通常是大于0的, 所以我们要把前面那段受到decelerate影响的部分去掉,在有导航栏的时候总是取小于 -64 或 -88 的第一个数作为y0,在此之前的数值会受到decelerate影响。

在BouncesObservation中,我们没有使用ScrollViewDidScroll捕获位移,因为ScrollViewDidScrollView只会在contentOffset发生变化时回调,这会导致Bounces快停止时一段时间内比较接近的contentOffset只回调一次,例如这段时间内本该是:[99.8、99.9、100.0、100.1、100.2], 由于机器根据像素点取整回调,所以只会回调一次100.0,这对我们的拟合会产生一些干扰,导致最终收敛不是很准确,所以我们统一使用CADisplayLink捕获这些值。

A参数分析:

由于A受冲击到边界的初速度影响,这个值在每组数据中都不是固定的,我们通过下面这些代换找到它和冲击初速度之间的关系:

∵ F*t = m*v
∴ ∫ kx dt + ∫ cv dt = m * (0 - v0)

x和v带入得:

A * ∫(一堆和e^(δ*t)相关的函数)dt = -v0

不必计算这个积分,看出A和V0线性相关就可以:

k * A = v0

如果是线性关系的话,我们只要找随机的一组先减速后冲击边界的数据,算出刚好碰撞时的V0,再与这个A做比就可以求出这个系数k了(这个k不是上面那个劲度系数,就是随便的一个系数)。

例如,我们有:松手时的速度v, 与边界的距离y,梯度下降得出的A。

那么,刚接触到边界时的速度:

v0 = v - 2*y,
k = v0/A

就求出了这个比例系数,这个系数恰好是1。

其他数值:

劲度比:k/m ~= 119.f

阻尼比:C/m ~= 21.8f

Bounces递推计算:

我们通过CADisplayLink做Bounces动画时,需要每次做动画时的计算过程保持独立,也就是给定一个状态S0,我们需要知道0.0167s后他的状态S1,从而在每次DisplayLink回调更新状态,来实现这个动画。

这个状态包含两个元素:距离边界的位移y 、当前的速度v;我们需要根据每次给定的{y、v}计算出整个公式的{A、t}, 然后将下一个状态的(t+Δt)带入公式,计算出新的{y1,v1},用新的状态更新当前的ScrollView,以此类推。

我们尝试对原公式做一下变换:

y = A*t*e^(-δ*t);     (1)
求导:v = A*e^(-δ*t) + A*t*(-δ)*e^(-δ*t) ;   (2)
两边相除:t = 1/((v/y) + δ)  (A)
t带入(1): A = y/(t * e^(-δ*t))  (B)

所以我们可以通过结果1和结果2分别计算出t和A,再在t上做累加,计算下一个v、y。直到v足够小,小到我们可以忽略的程度,就可以结束这个Bounces动画了。

Demo使用:

这个Demo中共包含了5个模块:

  • DecelerateObservation:用于观察阻尼减速的Demo。
  • BouncesObservation:用于观察边界弹性的Demo,使用CADisplayLink捕获位移,捕获结果可以用来做趋近实验。
  • Optimization:用于做Bounces的式子的参数优化,提供了三组数据,也可以从控制台上复制BouncesObservation输出的数据跑(剔除导航栏的影响、剔除Decelerate阶段的影响)。
  • GestureObservation:用于观察PanGesture在边界的有效位移转化率,拉伸得约明显,这个转化率越低,我推测这个位移转化率是指数与线性函数的线性组合,类似:convertPercent = ay + be^-y。a代表了屈服程度、b代表了倔强程度,其中屈服程度占12成,倔强程度占89成,但我暂时还没有办法完全证实这种转化率是什么原理,所以不在这里乱讲。
  • ScrollView:这个就是我们使用上面这些原理制作出自定义ScrollView,上面提及的所有交互分别在x、y两个方向上独立生效。

杂谈

经过这些讨论,我们已经意识到“自己制作一个UIScrollView”确实不是一件容易的事,“理解contentOffset”仅仅是其中的一点。所以,最好不要把这两个问题混为一谈。如果面试官使用前者向你发问,那你可要小心了,除了对iOS视图原理的一些理解外,他可能也想考察一些比较基础的知识。

如果有Android的同学需要实现自己的Bounces或是Decelerate,也可稍微参考这里得出的一些结论;一位做Android的同事告诉我Android的Bounces效果都是自己实现的,而且一般需要依赖两层视图,并顺便给我展示了一下米UI中滚动列表的视图层级,我体验了一下米UI中设置列表,如果能在边界处拉伸的距离转换上增加一些线性成分,个人感觉体验会更好一些。