UIScrollView位移转化率和分页机制

128 阅读7分钟

引言

在前面的一些文章中[深度剖析UIScrollView与阻尼动画]{juejin.cn/post/702145…},我详尽地介绍了复刻一个粗糙UIScrollView的理论依据;包含两个主要交互:Decelerate和Bounce的动力方程;本篇来讨论构成UIScrollView的另外两块拼图:Drag和PageEnable(还有个Zooming是用来处理双指捏和进行放大缩小的,不在本文范围内)。

什么是Drag?根据字面意思:Drag描述的就是拖拽行为,其本质就是将操作手势映射到视窗(bounds)的偏移量。显而易见的,当你的视窗位于列表中央时,这个映射是1比1映射;当你的视窗位于边界处时,这个映射比例会变小;本篇的主题之一就是讨论此边界发生的形变与手势之间的关系;事实上根据最终的结果,你的手指并非直接拖拽视窗,而是通过一根弹簧来牵引视窗,且每个列表都存在独特的极限距离。

什么PageEnable?目前几乎所有App中的短视频列表都是PageEnable的,每个视频是一个屏幕大小的格子;其运动方程与Bounce一致,就是上面的第二个,参数10.9改成50;本篇主要讨论的是如何判定是切到下一页或是回到上一页。

Drag

本文提供了一个辅助Demo,用于发现一些规律:Demo -> panGesture

- (void)dealWith:(UIPanGestureRecognizer *)panGesture
{
    if (panGesture.state == UIGestureRecognizerStateBegan) {
        self.tracking = YES;
        self.gestureStartY = [panGesture locationInView:self.view].y;
        self.scrollViewStartOffsetY = -self.collectionView.contentOffset.y;
    } else if(panGesture.state == UIGestureRecognizerStateChanged) {
        CGFloat gestureYOffset = [panGesture locationInView:self.view].y;
        CGFloat scrollViewOffset = -self.collectionView.contentOffset.y;
        CGFloat deltaGestureY = gestureYOffset - self.gestureStartY;
        CGFloat deltaViewOffset = scrollViewOffset - self.scrollViewStartOffsetY;
        CGFloat percent = deltaViewOffset/deltaGestureY;
        [self.functionView addPoint:[PanGestureFunctionPoint pointWithX:scrollViewOffset y:percent * 100]];
    } else {
        self.tracking = NO;
        self.gestureStartY = 0.f;
        self.scrollViewStartOffsetY = 0.f;
        self.fit.pArr = [NSArray arrayWithArray:self.functionView.points];
        [self.fit fit];
    }
}

核心代码在这:这个手势是单独加载VC.view上的panGesture,虽然大家都知道UIScrollView中有一个panGesture,但是苹果就是不给你看;所以我们只能单独加个复制版本,加在VC.view上是因为panGesture的位置信息受到bounds影响,加到collectionView上还要减去contentOffset比较麻烦;不必担心加载不同的View上两个手势出现距离偏差的问题,因为这些手势都是同一个硬件屏幕传进去,只是一个硬件手势分别给了两个recognizer响应,偏移量完全一致;同时,有个shouldRecognizeSimultaneouslyWithGestureRecognizer代理允许让两个手势同时响应,也不会影响UIScrollView里面的手势,这里不过多赘述。

说明下统计的数据:

  1. 在手势开始时分别记录Gesture的起始点和contentOffset的起始点;因为我们需要讨论gesture偏移->contentOffset偏移的有效转化率,所以这里需要记录起始点位置,以便后面分别减出来变更量。

  2. 在手势变更时分别记录当前Gesture位置和contentOffset起始点位置;分别用当前的位置减去起始点的位置,得到了两者的偏移量;这两者是相反的,我们只讨论数值关系不考虑方向,所以加了个负号让两者同号(或者两者都加绝对值也行)。

  3. 用View的偏移比手势偏移,得到一个转化率:percent,这个percent越大说明转化率越高,有更多的手势偏移能够生效为View的偏移。

  4. 把当前的View偏移量做为x,这个percent作为y传到FunctionView中,这个FunctionView会把这些点绘制到一个视图上,让你观察这两个量之间的关系,每次有手势生效或移动,都会在FunctionView上画一个点,我们通过观察这些点连成的函数图像来分析转化率问题。

现在,可以在这个Demo中向下拉几次这个列表,类似下拉刷新的操作;在顶部的FunctionView上有一条红色的线条,看起来是:

pAoExZ8.png

显然,这是一条直线(我觉得我眼睛没问题,这应该是条直线吧);OK,剩余的工作Demo会自动帮助你完成,它会将FunctionView中的点拿去做一个直线的拟合,帮你算出k、b输出到控制台,如:

k: -0.05899999850953463, b: 55.43200263287872 variance: 0.2501578646705071
k: -0.05679999856511131, b: 55.05600261501968 variance: 1.128432339544246
k: -0.05459999862068798, b: 54.81000260333531 variance: 1.957424197533212
k: -0.05469999861816177, b: 54.8130026034778 variance: 2.020247985129684
k: -0.05429999862826662, b: 54.735002599773 variance: 2.607643740866019
k: -0.05489999861310935, b: 54.74300260015298 variance: 3.683666240961233
k: -0.05509999860805692, b: 54.78600260219537 variance: 4.007696489900047
k: -0.05569999859289965, b: 54.89400260732509 variance: 6.396750847323987

点越多越准确,它最后会收敛到k=0.055,b=55k=0.055,b=55 ,variance是优化时候用的方差,可以看到有几百个点的时候这个方差只有个位数,已经很准确了(这里出现大方差的情况应该是步长设大了导致没优化到底,直线拟合不会出现掉入局部最优的情况);因为之前我们说的percent值通常是在1以下,比较小,我们画图象的时候乘了个100,以便能观察到这条直线的斜率和起始点,所以最终结果应该是k、b同时缩小100倍:

整理一下这些信息,以得到offset和gesture转化率方程:

1.设gesture的偏移量是x。

2.设转化后的view.offset是f(x)f(x)

3.设percent是g(x)g(x)

f(x)f(x)

1.根据g(x)g(x)是视图偏移和手势偏移之比:

g(x)=f(x)xg(x) = \frac{f(x)}{x}

2.根据那个直线的形式:

g(x)=kf(x)+bg(x) = kf(x)+b

g(x)g(x)去掉,f(x)f(x)搞到一起:

f(x)=xb1kxf(x) = \frac{xb}{1-kx}

完事了,xx是手势偏移,f(x)f(x)是视图偏移,它们两个满足这个关系,验证一下,把它改成:

f(x)=bk(1kx)bk1kx=bkk2xbkf(x) = \frac{\frac{b}{k}-(1-kx)\frac{b}{k}}{1-kx} = \frac{b}{k-k^2x}-\frac{b}{k}

显然,xx越大,底下的kk2xk-k^2x越负,左边越接近于-0,所以单调增;当x趋近于正无穷时,左边趋近于0,收敛在bk-\frac{b}{k}。如果用上面两个参数话,假设你的手指可以在手机滑无限长的距离还不用松手,UIScrollView的边界会无限靠近于超出屏幕1000个点位置,但是永远也达不到那个位置,这个叫“无限接近,永不相交”。

一个需要注意的点是:这个k并不是一个常量,与UIScrollView对应方向的长度相关,在Demo中我给出了一个可以自行调节的变量来验证这一点:

- (void)viewDidLayoutSubviews
{
    [super viewDidLayoutSubviews];
    self.functionView.frame = CGRectMake(0, 0, self.view.bounds.size.width, 300);
    //这里
    CGFloat collectionViewHeight = 1000;
    self.collectionView.frame = CGRectMake(0, 300, self.view.bounds.size.width, collectionViewHeight);
}

本文给出一个大致的映射表,感兴趣可自行探究:

Heightkb
1000-0.000550.55
800-0.000690.55
600-0.000910.55
400-0.001330.55
200-0.002700.55

这个映射的意思是一个UIScrollView越矮/短,它边界处手势转化率降低得越快,极限位置越靠近边界,但转化率的初始值都是0.55。

以上就是拖拽时手势偏移转化成视窗偏移的数值关系,结论就是:对于常规充满屏幕的Feed列表,极限位置在-1000,但是永远也达不到-1000(列表高度超过1000时,k不会再减小,维持在-0.00055,即使他们frame.height超过手机屏幕)。

PageEnable

这里不再赘述分页方程的推导过程,和Bounce一样拟合的拟合步骤,直接给出:

本篇着重讨论UIScrollView是如何判定在手势结束后是滚到下一页还是回到上一页。

我们简化一下问题,假设前一页对应的坐标是y1y_1,后一页对应的坐标是y2y_2,手势结束时当前的位置为yy,速度为vv,那么显然:y1<y<y2,y2y1=frame.heighty_1<y<y_2,y_2-y_1=frame.height成立;vv正负任意;在研究问题时,我们考虑水平或竖直的单一方向即可,两个方向互不影响,且是完全一致的,所以以下我们只考虑竖直方向:

分两种情况讨论:

1.v>0v>0时:

yy更趋向于移动到下边界y2y_2;结果我们是知道的,可能翻过去或是翻不过去。

a. y+frame.height2>y2y+ \frac{frame.height}{2}>y_2: 这种情况必能翻过去,因为当前页面大部分内容都已经翻过去,且v向下,找不到回去的理由。

b. y+frame.height2<y2y+\frac{frame.height}{2} < y_2:这种情况有可能翻过去:考虑一种情况:当前的y已经近乎接近满足上面的a了,仅差了1pt距离,而此时恰好向下的速度vv补充了一点向下的趋势,它就翻过去;另一种情况:yy靠近y1y_1的,速度vv向下但非常小,补充不了很多向下的趋势,它就翻不过去。

那么在b这种情况下如何判定?

我们照搬弹性方程中的一部分结论,记得在之前讨论Bounce时,我们通过v=0v=0能得到一个某次弹性的最远距离,这个的最远距离和v0v_0是线性关系;

(注意这个v0v_0和本问题中的手势结束速度vv不是一回事,v0v_0是指弹性刚刚超出边界时候的速度,vv代表的是一个弹性动画已经超过边界的运行某个时间点的速度,也就是说这个vvv0v_0后)

那么在这个问题中的我们把哪个位置当做边界的点呢?显然是把y1y_1当成边界点,因为回退的时候也是回退到y1y_1的,所以v0v_0指的就是在y1y_1处的速度,那么显然以y1y_1开始Bounce这个向下的最远距离超过了y1+y22\frac{y_1+y_2}{2},那它就应该滚到y2y_2去,否则是回退到y1y_1

好了,这个条件找到了,这里我们需要算一下v0v_0

再化简一下问题:把y1y_1视为下边界,给定一个超过y1y_1的位置yyyy点的瞬时速度vv,如何算出y1y_1点的初速度v0v_0;在这里我们直接用Δy=yy1\Delta y = y - y_1,这个Δy\Delta y就表示超过边界的位移。这么算:

Δy=v0teδt,v=v0eδtδv0eδt\Delta y = v_0te^{-\delta t},v = v_0e^{-\delta t} - \delta v_0e^{-\delta t}

这里yy就是Δy\Delta yvv就是松手瞬时的速度,都是常数,用这个vv比上yy,能把指数和v0v_0都干掉,然后能求出这个tt

t=1δ+vΔyt = \frac{1}{\delta + \frac{v}{\Delta y}}

右边都是常数,所以这个tt是个能算出来的数,然后再把这个ttΔy\Delta y代进y的公式里:

v0=Δyteδtv_0 = \frac{\Delta y}{te^{-\delta t}}

右边全是常数,这个v0v_0就是那个数了;我提前把这个PageSimulator的代码列在这,这是一个分页模拟器在初始化的时候传入的参数,position就是yy;velocity就是vv;damping就是δ\delta;targetPosition就是从y1,y2y_1,y_2里面判定好的目标位置(offset就是Δy\Delta y),最下面两个赋值语句就是上面列的两个求ttv0v_0的两个算式:

- (instancetype)initWithPosition:(CGFloat)position
                        velocity:(CGFloat)velocity
                  targetPosition:(CGFloat)targetPosition
                         damping:(CGFloat)damping
{
    self = [super init];
    if (self) {
        self.damping = damping;
        self.targetPosition = targetPosition;
        self.offset = position - targetPosition;
        if (fabs(self.offset) < 1.f) {
            self.currentTime = 0.f;
            self.velocity_0 = velocity;
        } else {
            self.currentTime = 1.f/(self.damping + velocity/self.offset);
            self.velocity_0 = self.offset * exp(self.damping * self.currentTime)/self.currentTime;
        }
    }
    return self;
}

然后我们使用这个v0v_0就能算出一个y1y_1基础上向下偏移的最远距离:

Δymax=v0δe\Delta y_{max} = \frac{v_0}{\delta e}

如果y1+Δymax>y1+y22y_1 + \Delta y_{max} > \frac{y_1 + y_2}{2},视窗可以切换到下一页,否则不能。

(当然,也可以根据vv的正负添加一些倾向性:如果速度是向下,说明用户是倾向向下切换的,此时将上式2修改为一个更大数值,它会更倾向切换到下一页;否则修改为一个更小的值,它会更倾向切换到上一页)

2.v<0v<0时:步骤同上,只需要把y1,y2y_1,y_2调换即可。

总结

本文讨论UIScrollView的剩余的两种交互:Drag和PageEnable,结合之前的两个算式,这里给出以下方程组:

好的,从现在开始,我们将称之为“Li’s equations”,逐一说明一下它们分别代表了什么,从上到下分别是1~4:

  1. 描述减速的方程:它表示了“位移差等于速度差的一半”,等价于v=v0e2tv = v_0e^{-2t},为左侧看起来统一,这里进行一次不定积分转成yy表示;它的物理含义是:牛顿流体在流动时受到的剪切应力与流速呈正比,它的官方写法是:τ=μdudyτ=μ\frac{du}{dy},在本文中这个μμ就是2,称为粘滞系数;我们不用写这么复杂,只需要写成:a=2va=-2v,即加速度总是速度的-2倍。

  2. 描述弹性的方程:它表示了“边界处弹力和阻力总是满足临界阻尼”,在粘弹性力学中,有两种常规的建模:麦克斯韦模型和开尔文模型,前者由一个弹簧和一个黏壶串联,接近粘弹性流体;后者由一个弹簧和一个黏壶并联,接近粘弹性固体;“黏壶”理解为一种没有管壁摩擦和气压的注射器或打气筒,限定了只受到内部流体的摩擦;该式体现了并联后的弹簧和黏壶之间总是满足“临界阻尼”,即将该模型压缩后松手,恰好满足弹簧不发生震动。

  3. 描述边界强化阶段应力应变关系的方程(近似):韧性材料在受到外力作用时,经过弹性、屈服、强化、颈缩断裂四个阶段,例如:你用手指按一个金属盆的盆底,开始时他处于弹性阶段、发生线性形变;随着逐渐用力会进入屈服状态,不再发生形变;再用力,进入强化阶段,这个阶段的形变量就非常小,且非线性不可逆;最后你把盆怼漏了。UIScrollView一上来就是非线性的强化阶段,这个强化阶段可逆、不会断裂、存在极限位置(应该没见过谁能把列表拉出屏幕且无法恢复的)。

  4. 一个描述分页的方程,同2,可以看到分页处的弹簧要比边界处的粗。

至此,我想我已经充分了解了UIScrollView的全部特性,作为一个收尾工程,我将LNCollecctionView上传到Github;相比于此前的LNCustomScrollView,LNCollectionView不再是玩具,它有更好的程序设计和拓展性,进行了必要的拆分,且附带了从ScrollView->CollectionView的实现,以达到一个“可用”的状态;除了一些基础功能和回调之外,还包含了一些额外的拓展:例如:它把Decelerate拓展成了幂率流体(a=μvna=-\mu v^n),在n=1n=1时会回归到牛顿流体;提供了四个方向的冲量检测和发生工具,使用转接头连接不同的列表,就会在转接头内模拟一次碰撞,看起来像是在打台球;LNCollectionView的结构和这些额外的小玩意放在后面的文章中介绍。除iOS外,这个开源项目的根目录下也有一个安卓工程,完全就是把iOS的版本的LNCollectionView翻译到了安卓平台;现在,安卓平台上能获得与iOS近乎一致的列表体验。

那么在已经有UICollectionView的情况下,LNCollectionView有什么必要性?它只会在一些冷门的场景发挥作用,这些冷门的场景往往是UICollectionView使用常规手段难以实现的,基本上都是由iOS闭源特性导致的,举例:

  1. 如果你希望PageEnable的每个Page都是不同大小,在某些情况下这是好实现的,例如:如果你能保证每一页的大小都小于容器的大小,这样可以通过定制FlowLayout的targetOffset方法实现;当页面大小大于容器大小时,你就需要在每个Cell中嵌套一个ScrollView来实现,同时你必须设置内部ScrollView的bounce=NO以避免手势出现问题或者两侧出现空白,这样设置后你在单个Page的边界处会有明显的顿感,比起前者这是可以接受的,但并不完美。
  2. UIScrollView的PageEnable是常规手段所无法控制(注意我说的常规手段是为了排除一些违规操作,例如:调用私有接口),你只能告诉它是否开启分页,而不能控制翻页速率、判定条件这些精细的特性;在某些场景中你觉得应该快一点来让用户尽快切换到下一页内容;而另一些场景中你觉得应该慢一点,因为两页的消费内容有一定连续性;非常遗憾iOS只有一套统一的UICollectionView。
  3. 在一个某些嵌套场景中,例如:一个有Header的垂类分页控制器,这种结构常见于个人页、话题页;他们通常会在顶部放置一个当前用户的可伸缩头,底部会分置几个垂类列表,用来承接这个用户/话题的一些子内容,例如:他的视频、收藏、帖子、商铺等等;毫无疑问外层的纵向结构会在触底后吃掉手势产生的冲量,LNScrollView提供了转接头,来让这些残余的冲量在内层结构继续发光发热,燃至最后一刻!
  4. 如何实现一个子视图可拖拽的容器?当然这种直接把PanGesture偏移转化成Frame偏移就可以,只是会显得很呆;LNScrollView的一些子组件可以让这个操作变自然。
  5. etc.

上面都是些冠冕堂皇说辞,这并非我的本意;总结一句话就是:如果我学会了如何造假钞,我可以凭心情在上面印任何我喜欢的东西,还能顺手发个抖音装个逼(但是千万不能花)。