深度剖析UIScrollView与阻尼动画

知识小集

共 9239字,需浏览 19分钟

 ·

2021-09-25 23:10

作者 李为

李为 iOS工程师,公众号:快看技术产品团队深度剖析UIScrollView与阻尼动画


摘要

UIScrollView是iOS开发中不可或缺也是使用最多的基础组件;常用的Feed流、Pager、轮播图等等都与其存在密不可分的联系。日常开发中,我们通常局限于必要的几个调用接口和代理,而不曾探究隐藏在几个简单接口背后的故事,比如:滚动视图如何在有限的区域内展示无限的内容?每一次在滚动区域触控屏幕会产生哪些反应?它在现实世界中又是怎样的物理形态?本文从基本的参数观测开始,以数学、物理学和优化方法中的一些基本方法和概念为工具,探索UIScrollView流畅交互背后隐藏的规律,共同领略苹果工程师的精妙设计。



  • 摘要

  • UIScrollView的局部显示原理

  • UIScrollView交互细节

  • Decelerate运动探究

  • Bounces运动探索

  • 参数测量方法

  • 实际开发中的用途

  • 结语


UIScrollView的局部显示原理

为了印证这部分观点,我们从苹果的官方文档上摘抄了一段描述:

Documents:UIScrollView is the superclass of several UIKit classes, including UITableView and UITextView.

A scroll view is a view with an origin that’s adjustable over the content view. A scroll view tracks the movements of fingers, and adjusts the origin accordingly[1]. The view that shows its content through the scroll view draws that portion of itself according to the new origin, which is pinned to an offset[2] in the content view. By default, it bounces[3] back when scrolling exceeds the bounds of the content.

这段文字的大意有以下几点:

  • UIScrollView追踪手指的动作并适当调整显示内容的原点(一个矩形左上角所在的坐标)。
  • 根据被调整矩形的原点坐标展示不同的内容。
  • 默认在接触到边界的时候会反弹。
  • 同时,UIScrollView是UITableView、UITextView的父类。

从UIScrollView的父类UIView的角度出发,UIView的属性:bounds.origin(x,y) 标记了一个UIView的所有子元素依赖的参考系原点,被添加在这个UIView上的所有子视图在绘制时均会参考这个原点,这意味着:如果这个原点被标记为{-40, -40.f},那么这个视图的所有子视图都会基于(-40, -40)这个点绘制。例如,这种情况下,一个frame = {20.f,20.f,100.f,100.f} 的子视图 会从点(-20.f, -20.f) 开始绘制,-20的来源是子视图的原点(20,20)加偏移量(-40,-40),所以,在此种情况下你只能看到这个子元素右下角大小为20*20的一小部分,其余超出边界的部分无法看到。

图片1.png

在UIScrollView中,为了将这个特性与常规的UIView区分开来,bounds.origin 被独立出来叫做:contentOffset,两者都包含两个数值x、y的二维向量(CGPoint),只要根据用户手势、动画规律不断变更contentOffset,就能做出滚动的效果。


UIScrollView交互细节

我们汇总了在setContentOffset的之前需要考虑的所有情况:

  1. 当panGesture在容器的规定范围(contentSize)内生效时,UIScrollView通常要移动相同的位置和方向,contentOffset与panGesture位移距离保持1:1数值关系。
  2. 在panGesture结束后,如果Gesture有剩余速度依然生效,需要持续进行减速。
  3. 在panGesture结束后,如果当前的contentOffset超出了contentSize,需要反弹并恢复到边界。
  4. (2)和(3)结合,panGesture结束后,有速度依然生效,在之后的一段时间内减速,减速未结束触及边界,回弹到非拉伸状态。
  5. 根据正交向量互不影响,弹性和减速需要在x、y两个方向上独立生效。
  6. 在超过边界进行panGesture时,panGesture转化为contentOffset距离的有效比例逐渐减小,呈现出一种拉扯到极限的效果。

这些交互特性共同作用成就了UIScrollView的绝佳交互体验。


Decelerate运动探究

1.数值观测

由简入繁,首先从比较容易的Decelerate动画开始,观察这部分动画的运行规律,我们不妨创建一个常规的UIScrollView,在以下代理中打印出统计信息 :

- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset
{
NSLog(@"DecelerateVelocity:%lf", velocity.y);
NSLog(@"DecelerateDistance:%lf", targetContentOffset->y - scrollView.contentOffset.y);
}
  • DecelerateVelocity记录了,panGesture手势结束瞬间的瞬时速度,也就是Decelerate起始速度。
  • DecelerateDistance记录了,panGesture结束后直到停止,在这段时间内,自然减速移动的总距离

我们截取了几次panGesture手势结束瞬间捕获到的数据,为了保证不受到bounces和边界的影响,应当尽量保证这些减速过程中不会触及边界,数据如下:

序号减速初速度截止到停止的移动距离
15.02709562506.5
21.802126895.0
31.412374700.5
41.687861838.0
  • 不难看出二者之间的关系:不考虑1000倍的话,减速的初速度总是它依靠惯性移动总距离的2倍。

关于这个1000倍:我们从这个代理的注释中可以看出:

Called on finger up if the user dragged. velocity is in points/millisecond. targetContentOffset may be changed to adjust where the scroll view comes to rest

那么这个速度的单位是points/millisecond,而通常的速度应以points/second为单位;因此,我们默认为这个速度乘1000以方便之后的计算,同时我们将1point的宽/高视为单位长度1米,这样速度单位也被我们统一为标准速度单位:m/s。


2.结果分析

根据上面的观察结果,我们试图寻找一种常用的函数,这个函数的特点是:他对时间(t)的导数(v)似乎总他自身(y)的两倍。

显然这个函数和其导数分别是:

这两个函数是指数加速,而由于Decelerate是减速,所以我们需要根据实际情况增加一个负号来保证速度总是随着时间逐渐减小的,所以有:

(细心的同学可以发现除了指数增加了负号外,常数前面也加了一个负号,这是因为我们在上述观测数值的时候velocity直接使用了初速度,而位移却是用了末位移减初位移;而正确的做法是使用速度改变量,也就是,因为末状态是停止状态;因此真正的结论应该是:速度的改变量应当总是位移改变量的-2倍,但此前为了数值观测方便,没有强调正负关系。)

那么根据已知条件:当减速刚开始时,,因此,右侧的常系数可以被固定为;而则是一个跟初始位置相关的常数,此处我们不关心。

那么最终我们得到的速度衰减公式为:


3.相关解释

以下是正常的推导来解释那个突兀的指数函数。

以不同的速度开始的减速效果满足相同的运动规律,不妨令>,那么减为0的必经状态,那么根据之前的观察结果:

两式相减:

当v1与v2无限接近时:

两边同时除以时间间隔dy:


两边积分:


显然;


4.结论

UIScrollView的减速运动就是阻尼系数为2的阻尼运动,这种阻尼运动对应于现实中的低速流体阻力。


5.其他

  • Q: 与 的倍数关系为什么是约等于?
  • A:这个不是误差,是Apple有意为之,因为阻尼减速是指数衰减,所以无限趋近于0的过程是非常漫长的,如果你看的关系会发现,即使是无限大,都不会减为0,因为指数总是正的,所以为省略后面那段很长很慢的减速,苹果就把它截断了。 这个截断量大概是 pt/s , 小于这个速度就会被截断,所以y总是比小一点(6左右)。所以,这其实是严格遵循自然规律原则为提升用户体验目标做出的让步。

Bounces运动探索

1.数值观测

我们如法炮制Decelerate观测过程,主要从时间空间两个维度观察Bounces运动规律,那么观测数值包括:

  • UIScrollView首次接触到边界时的初速度,一个直观的感受就是当这个越大时,反弹过程中能达到的最远距离就越长,可以理解为惯性越大。
  • 那么第二个值得观察的数值就是每次Bounces运动所能达到超过边界的最远距离。
  • 第三个数值为整个Bounces运动过程总持续时间,这个事件从首次接触边界开始,直到自然恢复到边界处结束。

具体的统计方法在此只做简要介绍,读者可自行编写Demo进行实践:

  • 为了方便区分,我们每次令UIScrollView冲击顶部的边界,这样当contentOffset.y为负数时,意味着Bounces动画开始,在此处记录一个起始时间
  • 为了获取到精准的初速度,我们每次在UIScrollView接触到边界前停止拖拽,令其依赖自身惯性进行反弹,这样我们就可以依赖上文Decelerate中提供的规律计算出接触边界时的初速度:(停止拖拽时的速度-停止拖拽时距离顶部的距离*2)。
  • 最远距离,通过在scrollViewDidScroll代理中不换捕获最小值获取。
  • 结束时间,通过endDecelerate代理捕获(Bounces结束时也被视为减速结束)。

经过这些记录后,我们成功捕获了几组数据:

序号接触边界时初速度(p/s)Bounces最远距离(p)Bounces持续时间(s)
1986.497-32.50.6668
22404.116-80.50.7509
31793.594-600.7337
41251.628-41.50.6836

观察到的现象:

  • 与最远距离总是正比例关系,这个比例大概是30。
  • Bounces运动总时长不会随着惯性的变化而发生太大改变,这个值基本稳定在0.6s~0.8s之间。这是典型的阻尼运动特点,类似于log(n)时间复杂度的算法不会n产生较大增量。

那么显然Bounces中存才的两种力:弹力和阻力;弹力用来保证接触到边界发生反弹,阻力用来限制弹力的简谐振动。


2.结果分析

我们列出经过以上两种合力的数学模型:

  • 首项中为胡克定律弹力模型中的劲度系数为弹簧被压缩的长度,也就是UIScrollView超出边界的距离。因为这个弹力总是与相反,所以符号是负号。
  • 次项中为阻力的阻尼系数(参考Decelerate中的数值2),阻力与速度的方向总相反,所以符号为负号。
  • 整个式子就是我们熟知牛顿第二定律:合力等于质量乘加速度。

另外一个我们熟知的结论是:瞬时速度是位移对时间的导数;瞬时加速度是速度对时间的导数,也就是位移导数。那么上述方程可写成如下形式:

等式两侧同除质量m:

这是个二阶线性齐次微分方程,根据其特征根返程解个数有三种不同形式通解,为了方便讨论特征根,我们对其系数做一些代换,令:

注意,由于等式中已经考虑了符号,所以阻尼系数和进度系数在此处均为正数;关于质量, 我们不考虑质量小于等于0的情况;因此,这些参数均为正数,代换后也为正数。

那么这个式子化简为:

其特征根方程为:

讨论其解形式

情况1:

特征根方程两相异实根,,其通解形式为:

情况2:

特征根方程一对相同实根,同为,其通解形式为:

情况3:

特征根方程实数域无根,复数域一对共轭复根:,其通解形式为:


这三种分别对应阻尼震动的三种形式:过阻尼、临界阻尼和欠阻尼

过阻尼欠阻尼临界阻尼.png

从用户体验角度讲,在不发生震荡的前提下反弹后以最快速度恢复到边界通常会获得最佳手感。因此在这里,苹果必然会选择临界阻尼为自己通用组件边界减震,因此我们选择临界阻尼条件下的通解形式:

此时,我们取,那么解特征根方程: ,得到:

将λ解带入替换通解的,得到:

根据初始条件:Bounces开始时,,得到,因此化简为:

是两个待定系数,后面介绍测量方法,这里直接给出:是首次接触边界时的速度是常数10.9,故Bounces公式为:


3.相关解释

特征根方程与微分方程的关系:

对于二阶齐次方程: ,考虑一个函数最容易凑出二阶导、一阶导、自身最容易提取出包含变量的公因式,并能够将剩余常数通过加减法抵消。

那么这个函数理应是指数函数,因为指数函数的导数总是能提取出自身:


如果确认了这种解形式,那么此方程可写为:


指数不可能为0,所以左侧多项式的解即是微分方程的解,所以这个简化后的方程就是特征根方程。


4.结论

Bounces运动是一种阻尼震动,这种震动依赖的弹性k和阻尼满足了一种特殊的数量关系,使得Bounces遵循临界阻尼震动,从而呈现出一种“回弹永远不会弹过边界或发生反复震动”现象。经过以上讨论,我们尝试构建了一下UIScrollView的内容展示区的复原图如下:

Bounces俯视图.png
  • UIScrollView的contentSize内部充满了阻力比较小的流体,可理解为水,在图中表现为蓝色部分。
  • contentSize外部充满了阻力比较大的流体,可理解为油,在图中表现为黄色部分。
  • UIScrollView的contentSize边界处安装了4根弹簧,每根的进度系数为119,在图中表现为紫色部分。
  • UIScrollView展示的内容区域为一块质量为1kg的光滑铁板,可以在整个区域内跟随手势自由移动,处于不同区域时受到相应力的作用。

参数测量方法

由于存在两个待定系数,而我们能观测到的数值主要是contentOffset,也就是位移, 的影响因素是速度、加速度这种更高级别的参数,通过观察位移确定难度较高,所以在这里我们使用一种简单的优化方法来让这我们预测的运动趋势与实际趋势逐渐吻合。


1.梯度下降思路

  1. 给定一组UIScrollView冲击边界触发Bounces的真实数据集合内包含Bounces动画期间内所有的contentOffset取值:
  2. 给定一组待定系数的解,通过公式  计算出所有的理论值
  3. 计算出理论值与实际值的方差:  ,函数越小,理论曲线与实际曲线的越接近。
  4. 如果我让中任意一个值,单独进行变化,能够让目标函数的值变小,那么就对这个变化予以肯定,将原值修改为变化后的值。对于三个待定系数,我们有六个方向进行变化,,当这六中变化中有多种变化都可以让目标函数结果变小,那么我们取变得最小的那个变化,因此这个方法也叫最速下降法。
  5. 当目标函数被优化到0时,说明我们的目标曲线与实际观测出的曲线已经完全重合了;而我们机器上观测到的数值总是离散的,所以实际上,这个数值被优化到个位数时就已经非常接近了。

以下是一组Bounces数据的优化过程:

cMfdpt.gif
  • 的值是我们为了让整体尽量收敛引入的偏移量,我们给出的公式: 的前提是:总是认为这个运动开始时  ,但我们实际取到Bounces开始时候的  的值也许并不恰好是0,因为手机中的屏幕刷新的信号都是离散的,每0.0167s一次刷新,所以大部分情况下是从一个正数如: 直接变化到了一个负数:,不会恰好落在0处,而我们取到的第一个值就是-10,这样的话曲线整体在时间上会有一个微小的偏移, 所以加入了这个变量 ,可以让结果收敛得更准确。
  • 一般didScroll的回调频率是小于CADisplayLink回调频率的,在滚动缓慢的状态下,离散取整可能导致contentOffset在某次刷新中不发生变化,也就是说didScroll的两次打点间隔有一定可能大于0.0167s,是2个或者3个刷新周期,因此使用CADisplayLink打点是最稳妥的方法,但后来经过实践这方面影响不大,因为低速状态下本身y值的差别就不大,对sum函数影响的比重非常小,所以使用didScroll打点,默认间隔是0.0167s即可。

最终我们对多组类似上面的数据进行测量, 值总是接近于10.9的一个数;而  则根据每次Bounces的力度不同发生变化。


2.参数C的确定

由于我们发现了的大小受到每次Bounces的力度影响,因此找到了一个包含两种参数的关系探究具体的算法,利用这个公式:

是合力,是持续时间,由于合力中的弹力、阻力两部分随时间变化,因此这两部都被写成积分形式(小是阻尼系数,大是待固定的常数)


然后把上面那里拿到的的公式:

代入到左边那个积分里:


发现左边的积分里面总是能提取出来一个 ,里面就没有  了,只剩一堆已知的量,这个时候恰好右面有个,所以  和初速度  是呈正比的,比例系数就是m除以剩余的那一堆积分。不用算这个积分,我们只需要找到一组Bounces的数据,通过减速的规律得到,通过上面那个优化方法优化出此次Bounces与实际值最接近的,(上面动图里的第二个变量:),求出此次Bounces中的比例就是所有情况下的固定比值,非常凑巧这个比值就是1。所以整体的算式就成为:


3.其他相关数值

劲度比:

阻尼比:


4.Bounces最远距离

根据 ,满足  的点即是最远处,得到:

得到:

两个结论:

  • Bounces总是会在开始后  秒时达到最远距离,到达最远距离的时间是固定的。
  • 最远距离确实与初速度呈正比,比例系数为  ,这两个:2.71828×10.9,近似于30,也就是我们此前在表格中观察到的30。

5.Bounces递推形式

考虑到我们自己实现一个CustomScrollView,在使用CADisplayLink执行Bounces动画时,已知的条件只有某个瞬间的瞬时速度和当前所在位置;而这个不一定0,也不一定是;所以在这里我们提供任意瞬时状态{}转的方法:


两式相除:

右侧等式化简的到t表达式:

带入y得到表达式:

这样,我们可以根据任意时刻的状态计算出完整的Bounces表达式和当前的时间t,然后,使用的算式计算出下次刷新时候的,不断更新这个状态,就实现了UIScrollView一样Bounces变化。


实际开发中的用途

1.使用Decelerate在两个UIScrollView减传递能量

我们首次应用到Decelerate是在一次对漫画专题页的大改版中。该次改版,专题页采用了一种多层UIScrollView嵌套的复杂结构,这个结构的最外层是纵向的滚动视图,承载了头部、可吸顶区域,和分页容器三个部分;分页容器是一个横向的滚动视图;内部又分为多个纵向滚动视图,由此组成了三层嵌套UIScrollView,如下:

专题页结构.png

用户在头部和吸顶区域向上拖拽后生效的是最外层蓝色的纵向视图,当蓝色的层级触底后,会有一个明显的停顿效果;因为父视图的手势生效,剩余的惯性无法传递到内部的橘黄色纵向视图;为了弥补这部分缺陷,让整个纵向列表看起来更加融为一体,我们为外层的蓝色视图分配了一个剩余速度(或冲量)检测的机制:

  • 这个Detector利用上文提及的 得到了触及边界后剩余的速度。
  • 乘自身质量m后通过图中的蓝色通路传递给当前选中tab对应的橘黄色视图。
  • 橘黄色视图配置了一个冲量接收机制Impulser,将接收到的除去自身质量,得到作用在自身的速度。
  • 橘黄色视图以此速度为初速度,执行向下滚动的减速动画。

Q:关于为什么传递A:我们为每个Detector和Impulser分配了额外的一个属性m,从而让这这些动效可以在二者之间以不同比例传递,这看起来就像:一个密度较大的球体撞向了一个密度较小的球体。通常状况下默认质量都是1,因此不同的UIScrollView之间的剩余速度会以1:1的比例传递。


2.使用Bounces实现POP类型弹幕

首次应用到Bounces动画是制作弹幕库时,由于平台特殊性,需要一种POP类型弹幕兼容漫画详情页弹幕播放,我们使用与Bounces类似的参数关系构建临界阻尼,使用此种曲线控制弹幕缩放:

POP.gif

为了节省性能,我们将几组配置好参数的临界阻尼曲线执行了间隔0.0167s的打点,并将这些点的数值存储在一个静态的数组中,弹幕轨道执行时直接从固定的几个数组中获取响应的数值,这样在使每个POP轨道中的弹幕均以相同的规律运行的同时,也不必去反复计算那些繁琐的指数,减少了普通POP动画执行时数值计算的大量性能消耗。


结语

苹果工程师们为开发者们构建和谐社会中处处透露着精致与优雅,作为iOS工程师的我们透过简单的几个接口和代理了解到其追求极致用户体验背后所付出的巨大努力。使用者只是轻轻触摸手机屏幕,即可在大海中畅快遨游;靠岸时又能体验到蹦床一般的新鲜与刺激感,别具一番风味;在充分享受刺激的同时,柔软的史莱姆将你身体充分包裹以免受任何伤害;人世间最大的快乐莫过于此



浏览 74
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报