likes
comments
collection
share

ScrollPhysics中的一些计算方法

作者站长头像
站长
· 阅读数 7
介绍:

ScrollPhysics一般是通过返回不同的simulation来控制ScrollView上的执行的动画的,其作用就是用来描述一个算式,它会接收一些比较通俗的参数,如:速度、位置、边界,然后通过solution方法把这些参数转化为算式中真正使用的参数,记录下来,过一段时间后使用记录下的参数算出该时间点的position值;当然,比较简单的算式一般是不用走_solution方法来额外计算参数,比较复杂的会_solution一下。

举个例子

如果方程比较简单,例如:你想描述一段匀速运动,一般需要传两个参数,初始位置 100,速度 10,那么这个simulation就会直接保存 originPosition = 100, originVelocity = 10,过了一段时间后(一般是下一个vsync),当需要更新偏移量时,这个simulation会被调用,传进来一个时间段t,是距离这个simulation初始化的时间间隔,然后simulation就会给出当前的位置newPosition=originPosition+velocity∗tnewPosition = originPosition + velocity * tnewPosition=originPosition+velocityt, newVelocity=originVelocitynewVelocity = originVelocitynewVelocity=originVelocity,滚动视图会拿着这个newPosition去更新偏移量,然后ScrollPhysics创建的一个新的这个simulation(也可能换成别的类型的Simulation,在ScrollPhysics可重写方法返回你需要的simulation),把上面的newPosition和newVelocity传到新的这个simulation的originPosition和originVelocity上,这个simulation等下一个vsync生效,然后再创建个新的simulation,一直这样进行下去。

如果方程比较复杂,在比如:你想描述一段Spring动画,但可传的参数一般是阻尼系数、劲度系数、质量这种,它们不能直接写在计算公式里面,所以需要额外进行一步solution,判断阻尼类型(阻尼系数和劲度系数的配比会影响阻尼的类型,它们对应微分方程不同解的形式),并把这些参数转成能直接写到算式里面的参数保存下来,其余流程和上面的匀速一样。

与列表相关的Simulation一般有这几个:

  • SpringSimulation/ScrollSpringSimulation:弹簧模拟器;后者是前者的子类,实际用到的是后者
  • FrictionSimulation:摩擦(减速)模拟器;iOS中的decelerate效果。
  • BouncingScrollSimulation:一个复合的模拟器,里面包括了Spring和Friction;超越边界相当于Spring,未超越边界相当于Friction,这个就是最终给列表使用的。
  • ClampedSimulation: 给任意一个simulation限制上下限,你可以传入任意子simulation进去,返回值会被ClampedSimulation限制上下限。
  • GravitySimulation: 模拟重力。

我们主要研究前三种,因为Flutter中的Bouncing是由另外两个复合的,所以实际只是Spring和Friction两种;这两种效果,分别对应于iOS中的Bounces和Decelerate。

Friction

Flutter中称之为“摩擦”,iOS称之为"减速",两个是相同的效果,它们的公式都是 v=v0e−2tv = v_{0} e^{-2t}v=v0e2t,意思是速度随时间呈指数衰减:速度越大摩擦力越大,这些内容在一篇文章中进行过详细的验证。有趣的是:当你读Flutter或者iOS的代码是看不到这个参数 −2-22 的,你能在iOS中看到的参数是 0.9980.9980.998

ScrollPhysics中的一些计算方法

在Flutter中看到的参数是 0.1350.1350.135

ScrollPhysics中的一些计算方法

而在LNCustomScrollView中我们用到的参数是 −2-22 本身,这三个数字表达的是同一个意思,感兴趣的同学可以验证一下:0.9981000≈0.1350.998^{1000} \approx 0.1350.99810000.135 , 而 0.135 =1e20.135 ~= \frac{1}{e^2}0.135 =e21。iOS的参数0.9980.9980.998的意思是:减速时速度每毫秒衰减为原来0.9980.9980.998倍,Flutter的参数0.1350.1350.135表达的意思是:减速时速度每秒衰减为原来的0.1350.1350.135倍,因为1s=1000ms1s = 1000ms1s=1000ms,所以每秒钟会进行1000次0.998的衰减,就有了第一个关系:0.9981000=0.1350.998^{1000} = 0.1350.9981000=0.135;至于0.1350.1350.135e−2e^{-2}e2就是纯数值相等,也就是说iOS/Flutter找了个参数0.9980.9980.998让每秒的衰减率恰好等于e−2e^{-2}e2,所以这三个数表达的意思相同。

解释完这个参数,我们回到Flutter的代码中,这个FrictionSimulation类

正常接收的初始参数有四个:

ScrollPhysics中的一些计算方法

  • Drag:这个就是衰减率,支持自定义,列表默认用的0.1350.1350.135
  • position:初始位置
  • velocity: 初始速度
  • rolerance:阈值的集合,举个例子,指数衰减收敛在0但永远不会等于0,可以一直衰减,这个时候你需要给出一个非常小的速度,让系统知道,当速度减到这个阈值的时候你可以把它视为静止,这个非常小的速度就是速度阈值;当然这个阈值的集合也可以包括时间、距离的阈值,在iOS中UIScrollView的速度阈值量大概是13pt/s,当减速到这个速度时,UIScrollView就停止滚动了,防止滚动很久带来一些比较差的交互体验。
_DragFor函数

ScrollPhysics中的一些计算方法

实际的应用时,有些场景期望是这样的:我只想让列表从位置A减速到位置B,中间怎么减,衰减率是多少我不想关心,例如:一个ViewPager,当手势落在两页之间时,我只希望他减速到上一页或者下一页,这个时候需求FrictionSimulation自行选择合适的衰减率,来满足“滚到特定位置”的需求;所以额外提供了一个Factory来帮助使用者计算一个合适的衰减率,在传入上述四个参数后through会调用_dragFor方法帮助用户计算衰减率,还是以ViewPager为例,当用户松手时速度比较大,算出来这个衰减率会比较接近于0,衰减得比较快,因为需要很强的衰减才能让滚动停止下来;反之速度比较小,这个衰减率就会比较接近于1,因为减太快的话还没滚动到指定位置就停了。

具体的计算可以拆成两步看:第一步是求出v=v0eatv = v_0e^{at}v=v0eat 的这个参数a(也就是我们那个−2-22),代码中的式子直接使用了v1−v2y1−y2\frac{v_1 - v_2}{y_1 - y_2}y1y2v1v2这种形式,这个式子是这么来的:

因为:v=v0eat v = v_0e^{at}v=v0eat,

两边积分(速度积分是位移):y=v0eata+Cy = \frac{v_0e^{at}}{a}+Cy=av0eat+C ,C是个常数

取任意两个时间点t1t_1t1,t2t_2t2对应的位移y1y_1y1 ,y2y_2y2, 和速度v1v_1v1,v2v_2v2则有

y1=v0eat1a+Cy_1 = \frac{v_0e^{at_1}}{a} + Cy1=av0eat1+C

y2=v0eat2a+Cy_2 = \frac{v_0e^{at_2}}{a} + Cy2=av0eat2+C

v1=v0eat1v_1 = v_0e^{at_1}v1=v0eat1

v2=v0eat2v2 = v_0e^{at_2}v2=v0eat2

把上面四个式子带入到v1−v2y1−y2\frac{v_1 - v_2}{y_1 - y_2}y1y2v1v2中 ,两个C消掉了,两个减式子除了底下有个a,其他一模一样,所以

a=v1−v2y1−y2a = \frac{v_1 - v_2}{y_1 - y_2}a=y1y2v1v2,因为t1t_1t1,t2t_2t2是任意取的,所以初末位置的速度、位移也符合这个式子。

这样算出a以后,再取个eae^aea就是一秒钟的衰减率。

FrictionSimulation执行solution后存的数就是这个drag,1秒的衰减率;还有一个额外dragLog 是对 drag取了个e为底的对数,Emmm,所以其实就是a。

x函数

ScrollPhysics中的一些计算方法

此处代码中用的都是x,我习惯用的y,都是位移的意思,这里因为代码中写的是x,所以这部分都用x了

这个x函数是根据输入时间t,算出当前位置xtx_txt;上面我

把两个xxxttt的表达式代替一下就是:

xt=x0+(v0eata+C)−(v0eat0a+C)x_t = x_0 + (\frac{v_0e^{at}}{a} + C) - (\frac{v_0e^{at_0}}{a} + C)xt=x0+(av0eat+C)(av0eat0+C)

因为我们规定初始的时间是0, 也就是t0=0t_0 = 0t0=0,t_2是当前的时间t, 把这两个带入之后简化一下就是:

xt=x0+v0∗eat−1ax_t = x_0 + v_0*\frac{e^{at} - 1}{a}xt=x0+v0aeat1

把这个eate^{at}eat换成 dragFortdragFor^tdragFort 就行,因为我们之前说过 dragFor=eadragFor = e ^ adragFor=ea

这个就是x函数的返回值

dx函数

ScrollPhysics中的一些计算方法

这个dx函数,表示的就是某个时间点的速度,xtx_txt求导即可,初始速度按照dragFor的衰减率进行衰减:

v=v0(dragFor)tv = v_0 (dragFor)^tv=v0(dragFor)t

finalX函数:

ScrollPhysics中的一些计算方法

这个finalX函数,意思是减速运动结束所在的位置,可以用来计算减速的极限位置是否能触达边界,如果触达边界会继续执行弹性,不会触达就只有减速;还记得我们上面提到过:a=v1−v2y1−y2a = \frac{v_1 - v_2}{y_1 - y_2}a=y1y2v1v2t1t_1t1t2t_2t2任意取,把这个式子交换一下:y1−y2=v1−v2ay_1 - y_2 = \frac{v_1 - v_2}{a}y1y2=av1v2;这个y1y_1y1就是现在的finalX,y2y_2y2看成初始位置 x0x_0x0 , 末速度是0,所以v1v_1v1是0,初速度v2v_2v2就是 v0v_0v0, 这个式子就变成了 finalX=x0−v0afinalX = x_0 - \frac {v_0}{a}finalX=x0av0

timeAtX函数

ScrollPhysics中的一些计算方法

这个函数用来计算滚动到某个位置对应的时间点,其中会出现double.infinity这种极限值,因为减速运动可能永远也无法达到某个非常远的位置,上面所说的finalX也用在了此处,如果位置超过了finalX,我们认为永远也无法到达那个位置。判断条件这里就不赘述了,这个返回结果就是根据上面那个x函数里面的公式,把t看成变量,x看成已知量,左右移一移动,搞一搞,就出来了。

isDone函数

如果当前的速度(dx)小于一定的阈值就视为停止。

以上就是FrictionSimulation中所有函数中的计算方法。

SpringSimulation

这个类的初始换参数有五个:

ScrollPhysics中的一些计算方法

  • spring: 描述spring参数的类,主要参数有三个:质量(mass),刚度(stiffness理解成弹簧的劲度系数k),阻尼(damp),这里面质量可以先暂时忽略,看成1就行。
  • start:初始位置
  • end:结束位置,也就是弹簧的平衡点
  • velocity:初速度
  • tolerance :阈值的集合

拿到这五个参数,Simulation会先执行一个_SpringSolution方法:

ScrollPhysics中的一些计算方法

这个函数叫“求解”:来把传入的数值解为可以用到公式中的参数,Emmm...这些参数就是二阶齐次线性常系数微分方程里面的c/r/a/b之类的,c一般是指数的系数,r一般是指数的幂系数,a、b一般是△<0时候,复数根的实部和虚部。整个springSimulation的计算基本都是围绕这个微分方程展开:

这个方程是背诵题,如果已经忘记了这个方程的解公式,可以温习一下:二阶常系数线性齐次微分方程

大致分三步:

1.找特征根方程(一元二次方程)

2.解特征根方程

3.根据解形式写答案

这里面_SpringSolution的三个子类: _CriticalSolution/ _OverdampedSolution / _UnderdampedSolution 就是根据特征根方程解的形式讨论最终方程的形式,判别条件:

ScrollPhysics中的一些计算方法

实际上就是特征根方程的△(b方减4ac):

  • △ > 0 ,俩实根,对应_OverdampedSolution过阻尼;
  • △ = 0,一个实根,对应 _CriticalSolution,临界阻尼;
  • △ < 0,俩复根,对应 _UnderdampedSolution 欠阻尼,这种情况会发生震荡,因为阻尼太小了。(这个cmk不知道是什么含义,它的结果就是:"delta")

上面就是整体思路,之后具体到每个参数:

讨论一个物体同时受到两个弹力和阻力的作用,弹力与到平衡点的偏移量呈正比,阻力总是与瞬时速度呈反比,质量为m,受力方程:

−ky−cv=ma-ky-cv = makycv=ma

对应到Flutter的代码里:

c 就是 springDescription.damping,阻尼系数

k 就是springDescrition.stiffness,劲度系数

m 就是springDescription.mass,质量

y 是距离弹簧平衡点的位移

v 是速度

a 是加速度

这么看就能看成微分方程:a 是 y'' , v 是 y' , y是y, t是变量:my′′+cy′+ky=0my'' + cy' + ky = 0my′′+cy+ky=0

那个判断条件cmk,就是 c2−4mkc^2 - 4mkc24mk, 哦!!!原来这个cmk是这个意思,名字起的有水平!

判断好条件就只需要用c、m、k根据需要算出三种解形式里面的c1、c2、r1、r2、a、b(这个r1、r2一般在教材里写的是:λ1、λ2)

△ > 0 时:

两个根就是二次函数解的公式,这公式是这么背的:“2a分之负b加减根号下b方减4ac”,这个算出的两个值分别做r1和r2。然后算c1、c2的时候需要根据实际情况: t = 0 时,位移是初始传入的distance,速度是初始传入的velocity ,然后对 y=c1er1t+c2er2ty = c_1e^{r_1t} + c_2 e^{r_2t}y=c1er1t+c2er2t 求个导数,v=c1r1er1t+c2r2er2tv = c_1r_1e^{r_1t} + c_2r_2e^{r_2t}v=c1r1er1t+c2r2er2t, 把t = 0带到上面两个式子你能得到:

distance=c1+c2distance = c_1 + c_2distance=c1+c2 (distance就是Simulation里面传的initialPosition,t = 0时的初位置)

velocity=c1r1+c2r2velocity = c_1r_1 + c_2r_2velocity=c1r1+c2r2

r1r_1r1r2r_2r2上面解出来了,用这两个式子解出来c1 c_1c1c2c_2c2就可以了:

ScrollPhysics中的一些计算方法

△ = 0的时候稍微简单一些:

直接使用 “2a分之负b”,就能找到唯一的一个r,然后用这个解形式:y=(c1+c2∗t)∗erty = (c_1 + c_2 * t)*e^{rt}y=c1+c2tert ,求个导数,用“左导乘右加右导乘左”:v=c2ert+(c1+c2t)rertv = c_2e^{rt} + (c_1+c_2t ) re^{rt}v=c2ert+(c1+c2t)rert,和前面条件一样,t = 0时:

distance=c1+0distance = c_1 + 0distance=c1+0

velocity=c2+c1rvelocity = c_2 + c_1r velocity=c2+c1r

算出:

c1=distancec1 = distancec1=distance

c2=velocity−c1rc_2 = velocity - c_1rc2=velocityc1r

ScrollPhysics中的一些计算方法

Emmm,这个c2c_2c2,不知道为什么flutter里面写成了c2=velocityc1rc_2 = \frac{velocity}{c_1r}c2=c1rvelocity,我验证了几次应该就是减号,这里写的除号,感觉是不小心将减号写成了除号,就给Flutter提了个小问题:[github.com/flutter/flu…] ;如果这里确实有问题,这个问题也不会经常出现,因为临界阻尼出现的条件非常苛刻,大部分情况下应该都是过阻尼,只有damp和stiffness恰好满足了cmk = 0的情况下才走临界阻尼。

△<0 的时候稍微复杂一些:

我们需要先求出共轭复根的实部和虚部,代码里面的w就是虚部:

数值等于根号下 △的绝对值 : 4ac−b22a\frac{\sqrt{4ac - b^2}}{2a}2a4acb2,(因为b2−4acb^2-4acb24ac 总是小于0的,所以绝对值直接写成了4ac−b24ac-b^24acb2) , a代成m, b 代成damping , c 代成 stiffness , 求出w。

r就是实部:−b2a\frac{-b}{2a}2ab , b代成damping,a代成m,求出r。

得到: y=ert(c1cos(wt)+c2sin(wt))y = e^{rt} (c_1cos(wt) + c_2sin(wt))y=ert(c1cos(wt)+c2sin(wt))

老法子,求个导数:

得到:v=rert(c1cos(wt)+c2sin(wt))+ert(−c1wsin(wt)+c2wcos(wt)) v = re^{rt}(c_1cos(wt) + c_2sin(wt)) +e^{rt}(-c_1wsin(wt)+c_2wcos(wt))v=rert(c1cos(wt)+c2sin(wt))+ert(c1wsin(wt)+c2wcos(wt))

t = 0时:

y=c1+0y = c_1 + 0y=c1+0

v=rc1+wc2v =rc1+wc2v=rc1+wc2

所以:

c1 = y

c2 = (v - r * c1) / w

ScrollPhysics中的一些计算方法

以上就是三种运动公式所有参数的求解方法。

总结一下:springSimulation的整体思路就是在创建的时候,根据cmk相对0的大小分别返回不同的阻尼子类,根据输入参数:初始位移、初始速度、弹簧刚度、阻尼系数、质量 ;计算出能表示运动方程的参数 c1、c2 、w、r 并保存下来,根据这些参数和自己对应的公式,在x()和dx()两个方法中给出一段时间之后的y和v。

而ScrollSpringSimulation 是一个倒推的入口,通过你输入的当前位置、当前速度、结束的位置、结束的速度(这个参数省略的,因为它总是0,所以没必要传),帮助你定制一根弹簧,这根弹簧能恰好让你的这些条件都满足:速度等于0的时候恰好滚动到结束的位置;这种定制规则一般在页面做pageEnable分割的时候使用到,每次松手后都会定制一个恰好滚到目标点的弹簧;而在页面边界处的四根弹簧则不会定制,它们是规格一样的。

BouncingScrollSimulation

到这里,我们已经研究了两个基础的simulation,之后把这两种组合一下就可以凑成一个BouncingScrollSimulation, bouncing代表反弹也就是Spring,scroll代表滚动+摩擦力也就是frictionSimulation;所以这个Simulation描述的运动包含两段,你给一个列表一定的初速度,在接触边界之前松手,这样他会先执行一个减速(1段),接触边界后执行一个回弹(2段);当然,它也可能接触不到边界,这样就只执行减速;也可能一开始就在边界外,这样就只执行回弹,反正所有判断都是在这个simulation内部做的。

和其他的Simulation一样,先研究下入参:

ScrollPhysics中的一些计算方法

  • position: 初始位置
  • velocity:初始速度
  • leadingExtent: 左边界/上边界
  • trailingExtend: 右边界/下边界
  • spring: 对弹簧的描述

收到参数后根据条件创建需要的simulation:

ScrollPhysics中的一些计算方法

  • 当前位置小于左边界的时候,创建一个收敛到左边界的弹簧
  • 当前位置大于右边界的时候,创建一个收敛到有边界的弹簧
  • 在两个边界的范围之内,创建一个可以自由滚动Friction:这里面参数0.135我们之前讨论过了,其实是 e−2e^{-2}e2,剩下的计算都是关于Friction和Spring的临界点的计算。

速度正向和反向是对称的两个运算,所以这里我们只讨论正向的:当速度大于0,且末位置大于右侧边界,这个Friction的末位置我们前面也讨论过,在这个参数下就是 position + velocity/2。如果这个末位置超过了边界,说明会撞击到右侧的弹簧上,我们需要找到恰好接触到边界的那个时间点,来对两段运动进行划分。此处用到的就是FrictionSimulation.timeAt()方法,传一个末位置,返回一个时间;

算出这个分割时间后,它会创建两个Simulation,前半段Friction,和后半段Spring;这两个会在 _simulation 这个属性的get方法中根据当前的条件:时间在Friction结束时间点的前/后,分别返回。

这样就实现了一个根据运动情况组合起来的:BoucingScrollSimulation。

(一句题外话:这里因为前半段是减速所以能算出这个时间精确值,想象另外一个场景:滚动视图的边界可能会把你可视区域弹出来,就像用弹弓发射一枚石子,发出去后会受到空气阻力,如果是这样的话,前半段是Spring,后半段是Friction,Spring的结束时间很难求,举个例子: 10t=et10t = e ^ t10t=et 的交点,只能求数值解,一般用牛顿迭代法,只要保证精度到小数点后3位基本就能保证在屏幕上不出问题,所以Simulation基类只规定要提供x() 、dx(),没有规定一定要提供timeAt()方法,因为暂时用不到,也求不出精确解,如果这个simulation需要支持任意几个运动需要先后拼接在一起的话,我想它是需要支持timeAt()方法的)

ClampingScrollSimulation

这个是安卓的,看起来用的是幂函数,我不认识这Penetration是什么意思,特意百度一下,明明是个动词,我不知道为什么要刻意加一个括号描述是什么东西做了这个动作。这里面有一些和iOS是共性的东西,flingVelocityPenentration函数是在描述一段减速运动,使用的是一个三次函数,但事实上他使用的也是指数,如果你记得常用的特勒展开式,其中有一个:

ScrollPhysics中的一些计算方法

你会觉得这个三次函数非常面熟,代码里长这样:

y=3.065t−3.27t2+1.2t3y = 3.065t - 3.27t ^ 2 + 1.2t^3y=3.065t3.27t2+1.2t3,(因为它限定了t总是0~1之间,越高次项对y的影响会越来越小,所以我倒过来写)

我再给出一个:

y=3.065te−1.13ty = 3.065te^{-1.13t}y=3.065te1.13t

把一式那个指数取泰勒展开的前三项,你会得到,在 t = 0.f附近:

y=3.065t(1−1.13t+0.57t2)=3.065t−3.46t2+1.75t3y = 3.065t(1-1.13t+0.57t^2) = 3.065t - 3.46t^2 + 1.75t ^ 3y=3.065t(11.13t+0.57t2)=3.065t3.46t2+1.75t3

虽然数值不是完全相同,但已经非常接近了;为什么会有偏差,因为矫正后的参数的特性:

t=0t = 0t=0t=1t = 1t=1带入到安卓的方程:

y=0−0+0=0y = 0 - 0 + 0 = 0y=00+0=0

y=3.065−3.27+1.2=0.995 =1y = 3.065 - 3.27 + 1.2 = 0.995 ~= 1y=3.0653.27+1.2=0.995 =1

再分别带入到我随便给的那个方程:

y=0−0+0=0y = 0 - 0 + 0 = 0y=00+0=0

y=3.065−3.46+1.75=1.35>1y = 3.065 - 3.46 + 1.75 = 1.35 > 1y=3.0653.46+1.75=1.35>1

所以,这个参数设置的目的应该就是让 [0,1] 的两端对齐(初末位置),这样直线就可以表示成曲线,运动末位置还是相同的;所以这个函数的目的就是根据距离和时间,给出一段阻尼运动的曲线,让他恰好和初末位置,初末速度和条件一样的匀减速运动一致;因为两端都是0和1,只有中间变化的快慢发生了改变,从直线变成了曲线,同样这个函数的导数也能表示速度的变化率。所以其实是用一个三次函数表示了一个指数。

总结:

这个调研的起因是:用到了bilibili漫画的日漫模式,其中有多页合并的功能,我尝试用iOS的原生控件(UICollectionView/UICollectionViewLayout)实现类似的效果,不是摩擦力大就是分割感弱,属于是鱼和熊掌不可兼得了;后来得知应该是使用Flutter实现,我从官网copy了个列表的Demo,实现了一个ScrollPhysics的子类传到了列表,分段返回不同的Simulation,实现了这种多页合并的效果,调研的时候搜到了很多关于ScrollPhysics的博客,大都是粗略提了一下ScrollPhysics、Simulation、Solution几个类之的关系,没有描述具体的计算步骤所以这里分享一下。

Flutter雀食好用,读源码,收获颇丰。