blog icon indicating copy to clipboard operation
blog copied to clipboard

知乎页面切换动效的探索——惯性滚动篇

Open campcc opened this issue 3 years ago • 1 comments

惯性滚动(momentum-based scrolling)一词我们并不陌生,从浏览器到桌面应用,再到移动 App,几乎所有涉及到滚动的一些场景,都能见到它的身影。惯性滚动最早出现在 IOS 系统中,当用户的滑动手势结束,手指离开屏幕后,页面滚动不会马上停止而是会根据滑动时的速度,滑动手势的强烈程度,继续保持一段时间的滚动效果,当页面滚动到顶部/底部时,还有可能触发 “惯性回弹” 的效果,

1.gif22.gif

-webkit-overflow-scrolling

IOS 系统的 Safari 浏览器最早支持了这一特性,我们可以为元素设置 -webkit-overflow-scrolling: touch 属性,让其支持惯性滚动,在 Safira 13+ 的版本 中,所有可滚动的框架或设置 overflow 滚动的元素默认支持了惯性滚动,

.view {
  -webkit-overflow-scrolling: touch;
}

遗憾的是在兼容性方面,除了 safari 外其他浏览器基本全军覆没,

此外,-webkit-overflow-scrolling 在 safari 上本身也存在一些问题

但是你可能发现很多流行的 UI 库,App 的交互都实现了惯性滚动的效果,那么在不支持 -webkit-overflow-scrolling 属性的浏览器上,我们要怎样实现 momentum-based scrolling 的动效呢?

首先我们对惯性滚动做一个简单的建模。

建模

在描述惯性滚动之前,我们先来回顾一下,什么是惯性?牛顿第一定律 表明,一切物体在没有受到外力的作用时,总是保持静止状态或匀速直线运动状态,所以惯性是物体的一种固有属性。那什么是惯性滚动呢?

我们可以把惯性滚动描述为,物体在受到外力的作用下,运动状态改变,但由于物体本身具有惯性,所以会保持原有的运动状态继续运动的过程。试想一下,如果在足够光滑的表面上,物体将一直保持惯性滚动,但实际场景中,物体往往会因为摩擦力,空气阻力等,速度变得越来越慢直到静止。

为了方面理解,这里我们可以用大家熟悉的 滑块模型 的几个阶段来近似描述这一过程,

第一阶段,滑动滑块(F拉 > F摩)使其从静止开始做加速运动slidermodal.png

由于在实际的场景中,我们一般关注的是用户即将释放手指前的一小阶段,而非滚动的全流程(全流程意义不大),所以这一瞬间阶段也可以简单模拟为滑块均衡受力做 匀加速运动

第二阶段,释放滑块使其在只受摩擦力的作用下继续滑动slowdown.png

这一阶段,滑块拥有一个初速度,且只受到反向的摩擦力做 匀减速运动。但在实际的场景中,我们一般会将惯性滚动的元素放置在一个容器内,这时滑块可能会滚动到容器边界触发回弹,所以我们还需要考虑回弹过程。

我们可以借助一根弹簧来近似模拟回弹的过程,假设滑块左端与一根弹簧连接,弹簧另一端固定在墙体上。当滑块惯性滚动到达临界点(弹簧即将发生形变)时,滑块会拉动弹簧使其发生形变,此时滑块受到弹簧的反向拉力作减速运动直到停止,

第三阶段,滑块受到弹簧的反向拉力和摩擦力做变减速运动Untitled Diagram (1).png

此阶段滑块受到弹簧拉力和摩擦力的共同作用,加速度越来越大,最后速度变为 0,此时弹簧的形变量最大。最后弹簧恢复形变,拉动滑块做反向的变加速和变减速运动,

第四阶段,弹簧拉动滑块做变加速和变减速运动

Untitled Diagram (2).png

基于上面的模型,我们可以试着来实现惯性滚动惯性回弹(这里以小程序代码为例)。

惯性滚动

初始化两个页面元素,容器 (container) 和滑块 (slider),

<!-- wxml -->
<wxs module="gesture" src="./index.wxs"></wxs>
<view class="container">
  <view
    class="slider"
    bindtouchstart="{{gesture.touchstart}}"
    bindtouchmove="{{gesture.touchmove}}"
    bindtouchend="{{gesture.touchend}}"
    bindtouchcancle="{{gesture.touchcancel}}"
  ></view>
</view>

对于模型的第一阶段,滑块做匀加速运动,假设滑块的滑动距离为 s1,滑动的时间为 t1,手指离开时滑块的速度为 v1,根据 位移公式

image

可以计算出惯性滚动的初始速度,

image

对于第二阶段,滑块受反向摩擦力做匀减速运动,末速度为 0m/s,假设滑块加速度为 a,滑动时间为 t2,滑动距离为 s2,结合加速度公式和位移公式,

image

可以计算出滑块滑动的距离,

image

由于匀减速的减速度为负(a < 0),这里我们不妨设一个加速度常量 A,使其满足 A = -2a 的关系,那么滑动距离,

image

但在实际应用中,我们发现 v1 算平方会导致最终计算计算出的惯性滚动距离太大(即对滚动手势的强度感应过于灵敏),这里我们不妨把平方去掉,

image

所以,求滑块的滑动距离时,只需要记录用户滚动的距离 s1 和滚动时长 t1,然后设置一个合适的加速度常量 A 即可。经过大量测试,这里加速度常量 A 的取值建议在 0.002 ~ 0.003,

var translateY = 0; // Y 轴的偏移距离
var startTime = 0; // 触发惯性滚动的起始时间
var startY = 0; // 触发惯性滚动的 Y 轴起始坐标
var deceleration = 0.002; // 惯性滚动的加速度常量

function touchstart(e) {
  startTime = e.timeStamp;
  startY = e.detail.changedTouches[0].pageY;
}

需要注意的是,对于实际场景的惯性滚动来说,我们这里讨论的滚动距离和时长是指能够作用于惯性滚动范围内的距离和时长,而非用户滚动页面元素的整个流程,比如下面的这个例子,

3

用户首先以非常缓慢的速度使元素滚动了一段距离,随后在很短的时间内继续滚动页面然后释放手指,这种情况下我们需要的滚动距离和时长应该是后半段用户在短时间内触发惯性滚动的手指移动距离和停留时长,换成代码描述就是,在 touchmove 事件里,如果用户在滑动的过程中,停留时长大于阈值,说明有可能会触发惯性滚动,此时我们需要更新惯性滚动的起始时间和位置

function touchmove(e) {
  // ...
  if (e.timeStamp - startTime > momentumTimeThreshold) {
    startTime = e.timestamp;
    startY = e.detail.changedTouches[0].pageY;
  }
}

为什么说是有可能触发惯性滑动呢?因为我们还需要判断用户滚动的距离,所以在触摸时间结束后,惯性滚动的触发条件是,停留时长 duration 小于某个阈值并且最小位移距离 s1 大于某个阈值。经过测试,这里的停留时长阈值设置为 200ms ~ 300ms,最小位移距离设置为 10px ~ 20px 较为合理,


var momentumTimeThreshold = 300; // 触发惯性滚动的最大时长,ms
var momentumYThreshold = 15; // 触发惯性滚动的最小位移距离,ms

function touchend(e) {
  var endY = e.detail.changedTouches[0].pageY;
  var duration = e.timeStamp - startTime;
  var s1 = endY - startY;
  if (duration < momentumTimeThreshold && Math.abs(s1) > momentumYThreshold) {
    // 触发惯性滑动...
  }
}

接下来我们来分析惯性滚动后可能会触发的第三,四阶段的回弹过程。

惯性回弹

对于模型的第三阶段,滑块受弹簧反向拉力和摩擦力的共同作用做变减速运动,滑块的加速度越来越大,速度降为 0m/s 的时间会很短,我们可以用一个近似的缓动曲线去描述这一过程,假设这里滑块触发了第三阶段后的速度曲线就是 ease-out,滚动时长为 t

1.png

我们需要计算滑块触碰容器边界后的滚动距离,也就是曲线在 0 ~ t 时间范围内与 x 轴围成的面积 s,用积分表示为,

image

我们把 ease-out 函数 $ f(t) = -t^2 + 2t $ 带入,

image

根据 牛顿-莱布尼茨公式

image

要计算上述积分,我们需要找到 ease-out 函数的原函数 F,使函数 F 满足,

image

原则上,只要我们定义了滑块的滚动时长和滚动曲线,就可以计算出滚动的距离,这里的滚动距离也是模型第四阶段的回弹距离,但是在实际应用中,这里的滚动曲线定义和原函数的计算是很复杂的,我们可以考虑把上述模型做适当的简化。

由于滑块在第三阶段的加速度是大于匀减速运动情况下的加速度的,

image.png

所以第三阶段滑块触碰边界后的滚动距离一定小于匀减速运动情况下的滚动距离,假设滑块第三阶段滚动距离为 s3,容器的边界值为 boundY,前面我们分析了滑块在匀速运动情况下的总滑动距离为 s2,那么一定满足,

s3 < s2 - boundY

这里我们不妨设一个回弹的阻力常量 B,使其满足,

s3 = (s2 - boundY) / B

经过测试,这里的阻力常量 B 的取值在 10 ~ 12 较为合适。到此,我们已经完成了对上述模型一些关键指标(滚动距离滚动时长启停条件等)的一个简单的实现,接下来我们来关注一下模型各阶段滑块的滚动效果(缓动效果),也就是速度曲线

缓动

现实生活中,物体并不是突然启动或者停止,或者一直保持匀速移动,在动效设计里,我们经常用缓动来描述物体的变速运动,让整个运动过程更加自然,CSS 提供了过渡和动画配合实现缓动动效。这里缓动我们需要关注两个重要的指标,缓动函数(easing function)和 缓动时长

我们可以用贝塞尔曲线来描述动画进程随时间的变化关系,接下来我们借助一些开源的在线绘制贝塞尔曲线的工具,如 cubic-bezier,对上述的滑块模型做一个简单的分析,

对于模型的第一、二阶段,也就是触发了惯性滚动,滚动结束后还未到达容器边界的情况,滑块先做加速运动,到达最大速度后触发惯性滚动,最后做减速运动直到停止,

4

整个过程的缓动曲线可以描述为 cubic-bezier(0, 0.5, 0.2, 1)

image.png

这里的缓动时长我们可以需要考虑根据惯性滚动的距离动态设置,比如定义强、弱两种惯性时长,然后给定一个强弱惯性的分割值,

var inertialThreshold = 100; // 强弱惯性分割值

.weekInertial {
  transition: transform cubic-bezier(0, 0.5, 0.2, 1) 1.5s;
}

.strongInertial {
  transition: transform cubic-bezier(0, 0.5, 0.2, 1) 3.5s;
}

对于模型的的第一、二、三阶段,滑块首先触发了惯性滚动,但惯性滚动的距离超过了容器的边界,超过边界后迅速减速直到最后停止,

5

整个过程的缓动曲线可以描述为 cubic-bezier(0.25, 0.46, 0.45, 0.94)

image

这里需要注意的是,容器除了要设定边界值外,还需要设置允许超过的最大边界,当我们计算出的滑块惯性滚动的距离过大(即用户的滑动手势过于强烈),大到已经超过允许的最大边界时,我们需要重置滚动的距离为最大边界。

对于模型的第四阶段,滑块从超过边界的某个值返回边界,类似于触发了回弹,

7

这个过程的缓动曲线可以描述为 cubic-bezier(0.16, 0.5, 0.4, 1)

image.png

这里回弹的触发方式有两种,用户滑动超过边界后回弹惯性滚动超过边界后回弹。前者是比较好监听的,可以在用户触摸事件结束时去做处理,但是对于惯性滚动超过边界后的回弹,我们要怎么知道惯性滚动是否结束了呢?

回弹一定是在惯性滚动之后(滚动我们设置了动画),可以通过 监听缓动动画的结束事件

function transitionend() {
  var overBounce = translateY > minY || translateY < maxY;
  if (overBounce) {
    if (translateY > minY) translateY = minY;
    if (translateY < maxY) translateY = maxY;
    ins.addClass('rebound');
    setStyle();
  }
}

此外,当惯性滚动未结束,或者正处于回弹过程,用户再次触碰元素时,我们应该要暂停缓动,如何让缓动停止呢?回想一下这里缓动我们是通过 CSS 样式去设置的,那么一个思路就是在用户再次触碰元素的瞬间,获取元素当前的计算样式,然后拿到我们需要的偏移量值进行重设,

function touchstart(e, instance) {
  // ...
  stop();
  // ...
}

function stop() {
  var computedStyle = ins.getComputedStyle(['transform']);
  var matrix = computedStyle.transform;
  var offsetY = +matrix.split(')')[0].split(', ')[5];
  if (matrix.indexOf('matrix') !== -1 && offsetY) {
    translateY = offsetY;
    setStyle();
  }
}

以上是小程序端惯性滚动的一个简单实现,我们也可以用类似的思路去实现其他平台的惯性滚动,可以点击基于上述思路的一个可运行的 代码片段,在微信开发者工具中打开体验。

参考链接

写在最后

本文首发于我的 博客,才疏学浅,难免有错误,文章有误之处还望不吝指正!

如果有疑问或者发现错误,可以在评论区进行提问和勘误,

如果喜欢或者有所启发,欢迎 star,对作者也是一种鼓励。

(完)

campcc avatar Apr 08 '22 01:04 campcc

👍👍

dnjat avatar Oct 30 '24 02:10 dnjat