做者:凹凸曼-吖伟javascript
咱们在平时编程开发时,除了须要关注技术实现、算法、代码效率等因素以外,更要把所学到的学科知识(如物理学、理论数学等等)灵活应用,毕竟理论和实践相辅相成、密不可分,这不管是对于咱们的方案选型、仍是技术实践理解都有很是大的帮助。今天就让咱们一块儿来回顾中学物理知识,并灵活运用到惯性滚动的动效实现当中。css
惯性滚动
(也叫 滚动回弹
,momentum-based scrolling
)最先是出如今 iOS 系统中,是指 当用户在终端上滑动页面而后把手指挪开,页面不会立刻停下而是继续保持必定时间的滚动效果,而且滚动的速度和持续时间是与滑动手势的强烈程度成正比。抽象地理解,就像高速行驶的列车制动后依然会往前行驶一段距离才会最终停下。并且在 iOS 系统中,当页面滚动到顶/底部时,还有可能触发 “回弹” 的效果。这里录制了微信 APP 【帐单】页面中的 iOS 原生时间选择器的惯性滚动效果:html
熟悉 CSS 开发的同窗或许会知道,在 Safari 浏览器中有这样一条 CSS 规则:前端
-webkit-overflow-scrolling: touch;
复制代码
当其样式值为 touch
时,浏览器会使用具备回弹效果的滚动, 即“当手指从触摸屏上移开,内容会继续保持一段时间的滚动效果”。除此以外,在丰富多姿的 web 前端生态中,不少经典组件的交互都必定程度地沿用了惯性滚动的效果,譬以下面提到的几个流行 H5 组件库中的例子。vue
为了方便对比,咱们先来看看一个 H5 普通长列表在 iOS 系统下(开启了滚动回弹)的滚动表现:java
weui 的 picker 组件git
明显可见,weui 选择器的惯性滚动效果很是弱,基本上手从屏幕上移开后滚动就很快中止了,体验较为很差。github
vant 的 picker 组件web
相比之下,vant 选择器的惯性滚动效果则明显清晰得多,可是因为触顶/底回弹时依然维持了普通滚动时的系数或持续时间,致使总体来讲回弹的效果有点脱节。算法
惯性
一词来源于物理学中的惯性定律(即 牛顿第必定律):一切物体在没有受到力的做用的时候,运动状态不会发生改变,物体所拥有的这种性质就被称为惯性。可想而知,惯性滚动的本质就是物理学中的惯性现象,所以,咱们能够恰当利用中学物理上的 滑块模型
来描述惯性滚动全过程。
为了方便描述,咱们把浏览器惯性滚动效果中的滚动目标(如浏览器中的页面元素)模拟成滑块模型中的 滑块
。并且分析得出,惯性滚动的全过程能够模拟为(人)使滑块滑动必定距离而后释放的过程,那么,全流程能够拆解为如下两个阶段:
第一阶段,滑动滑块使其从静止开始作加速运动;
在此阶段,滑块受到的 F拉
大于 F摩
使其从左到右匀加速前进。
须要注意的是,对于浏览器的惯性滚动来讲,咱们通常关注的是用户即将释放手指前的一小阶段,而非滚动的全流程(全流程意义不大),这一瞬间阶段能够简单模拟为滑块均衡受力作 匀加速运动。
第二阶段,释放滑块使其在只受摩擦力的做用下继续滑动,直至最终静止;
在此阶段,滑块只受到反向的摩擦力,会维持从左到右的运动方向减速前进而后停下。
基于滑块模型,咱们须要找到适合的量化指标来创建惯性滚动的计算体系。结合模型和具体实现,咱们须要关注 滚动距离
、速度曲线
以及 滚动时长
这几个关键指标,下面会一一展开解析。
对于滑动模型的第一阶段,滑块作匀加速运动,咱们不妨设滑块的滑动距离为 s1
,滑动的时间为 t1
,结束时的临界点速度(末速度)为 v1
,根据位移公式
能够得出速度关系
对于第二阶段,滑块受摩擦力 F拉
作匀减速运动,咱们不妨设滑动距离为 s2
,滑动的时间为 t2
,滑动加速度为 a
,另外初速度为 v1
,末速度为 0m/s
,结合位移公式和加速度公式
能够推算出滑动距离 s2
因为匀减速运动的加速度为负(即 a < 0
),不妨设一个加速度常量 A
,使其知足 A = -2a
的关系,那么滑动距离
然而在浏览器实际应用时,v1
算平方会致使最终计算出的惯性滚动距离太大(即对滚动手势的强度感应过于灵敏),咱们不妨把平方运算去掉:
因此,求惯性滚动的距离(即 s2
)时,咱们只须要记录用户滚动的 距离 s1
和 滚动时长 t1
,并设置一个合适的 加速度常量 A
便可。
经大量测试得出,加速度常量
A
的合适值为0.003
。
另外,须要注意的是,对于真正的浏览器惯性滚动效果来讲,这里讨论的滚动距离和时长是指可以做用于惯性滚动的范围内的距离和时长,而非用户滚动页面元素的全流程,详细的能够看【启停条件】这一节内容。
针对惯性滚动阶段,也就是第二阶段中的匀减速运动,根据位移公式能够获得位移差和时间间距 T
的关系
不可贵出,在同等时间间距条件下,相邻两段位移差会愈来愈小,换句话说就是惯性滚动的偏移量增长速度会愈来愈小。这与 CSS3 transition-timing-function
中的 ease-out
速度曲线很是吻合,ease-out
(即 cubic-bezier(0, 0, .58, 1)
)的贝塞尔曲线为
曲线图来自 在线绘制贝塞尔曲线网站。
其中,图表中的纵坐标是指 动画推动的进程,横坐标是指 时间,原点坐标为 (0, 0)
,终点坐标为 (1, 1)
,假设动画持续时间为 2 秒,(1, 1)
坐标点则表明动画启动后 2 秒时动画执行完毕(100%)。根据图表能够得出,时间越日后动画进程的推动速度越慢,符合匀减速运动的特性。
咱们试试实践应用 ease-out
速度曲线:
很明显,这样的速度曲线过于线性平滑,减速效果不明显。咱们参考 iOS 滚动回弹的效果重复测试,调整贝塞尔曲线的参数为 cubic-bezier(.17, .89, .45, 1)
:
调整曲线后的效果理想不少:
接下来模拟惯性滚动时触碰到容器边界触发回弹的状况。
咱们基于滑块模型来模拟这样的场景:滑块左端与一根弹簧链接,弹簧另外一端固定在墙体上,在滑块向右滑动的过程当中,当滑块到达临界点(弹簧即将发生形变时)而速度尚未降到 0m/s
时,滑块会继续滑动并拉动弹簧使其发生形变,同时滑块会受到弹簧的反拉力做减速运动(动能转化为内能);当滑块速度降为 0m/s
时,此时弹簧的形变量最大,因为弹性特质弹簧会恢复原状(内能转化成动能),并拉动滑块反向(左)运动。
相似地,回弹过程也能够分为下面两个阶段:
滑块拉动弹簧往右作变减速运动;
此阶段滑块受到摩擦力 F摩
和愈来愈大的弹簧拉力 F弹
共同做用,加速度愈来愈大,致使速度降为 0m/s
的时间会很是短。
弹簧恢复原状,拉动滑块向左作先变加速后变减速运动;
此阶段滑块受到的摩擦力 F摩
和愈来愈小的弹簧拉力 F弹
相互抵消,刚开始 F弹 > F摩
,滑块作加速度愈来愈小的变加速运动;随后 F弹 < F摩
,滑块作加速度愈来愈大的变减速运动,直至最终静止。这里为了方便实际计算,咱们不妨假设一个理想状态:当滑块静止时弹簧恰好恢复形变。
根据上面的模型分析,回弹的第一阶段作加速度愈来愈大的变减速直线运动,不妨设此阶段的初速度为 v0
,末速度为 v1
,那么能够与滑块位移创建关系:
其中 a
为加速度变量,这里暂不展开讨论。那么,根据物理学的弹性模型,第二阶段的回弹距离为
微积分都来了,简直无法计算……
然而,咱们能够根据运动模型适当简化 S回弹
值的计算。因为 回弹第二阶段的加速度
是大于 非回弹惯性滚动阶段的加速度
(F弹 + F摩 > F摩
)的,不妨设非回弹惯性滚动阶段的总距离为 S滑
,那么
所以,咱们能够设置一个较为合理的常量 B
,使其知足这样的等式:
经大量实践得出,常量
B
的合理值为 10。
触发回弹的整个惯性滚动轨迹能够拆分红三个运动阶段:
然而,若是要把阶段 a
和阶段 b
准确描绘成 CSS 动画是有很高的复杂度的:
b
中的变减速运动难以准确描绘;为了简化流程,咱们把阶段 a
和 b
合并成一个运动阶段,那么简化后的轨迹就变成:
鉴于在阶段 a
末端的反向加速度会愈来愈大,因此此阶段滑块的速度骤减同比非回弹惯性滚动更快,对应的贝塞尔曲线末端就会更陡。咱们选择一条较为合理的曲线 cubic-bezier(.25, .46, .45, .94)
:
对于阶段 b
,滑块先变加速后变减速,与 ease-in-out
的曲线有点相似,实践尝试:
仔细观察,咱们发现阶段 a
和阶段 b
的衔接不够流畅,这是因为 ease-in-out
曲线的前半段缓入致使的。因此,为了突出效果咱们选择只描绘变减速运动的阶段 b
末段。贝塞尔曲线调整为 cubic-bezier(.165, .84, .44, 1)
实践效果:
因为 gif 转格式致使部分掉帧,示例效果看起来会有点卡顿,建议直接体验 demo
咱们对 iOS 的滚动回弹效果作屡次测量,定义出体验良好的动效时长参数。在一次惯性滚动中,可能会出现下面两种状况,对应的动效时间也不同:
没有触发回弹
惯性滚动的合理持续时间为 2500ms
。
触发回弹
对于阶段 a
,当 S回弹
大于某个关键阈值时定义为 强回弹,动效时长为 400ms
;反之则定义为 弱回弹,动效时长为 800ms
。
而对于阶段 b
,反弹的持续时间为 500ms
较为合理。
前文中有提到,若是把用户滚动页面元素的整个过程都归入计算范围是很是不合理的。不难想象,当用户以很是缓慢的速度使元素滚动比较大的距离,这种状况下元素动量很是小,理应不触发惯性滚动。所以,惯性滚动的触发是有条件的。
启动条件
惯性滚动的启动须要有足够的动量。咱们能够简单地认为,当用户滚动的距离足够大(大于 15px
)和持续时间足够短(小于 300ms
)时,便可产生惯性滚动。换成编程语言就是,最后一次 touchmove
事件触发的时间和 touchend
事件触发的时间间隔小于 300ms
,且二者产生的距离差大于 15px
时认为可启动惯性滚动。
暂停时机
当惯性滚动未结束(包括处于回弹过程),用户再次触碰滚动元素时,咱们应该暂停元素的滚动。在实现原理上,咱们须要经过 getComputedStyle
和 getPropertyValue
方法获取当前的 transform: matrix()
矩阵值,抽离出元素的水平 y 轴偏移量后从新调整 translate
的位置。
基于 vuejs 提供了部分关键代码,也能够直接访问 codepen demo 体验效果(完整代码)。
<html>
<body>
<div id="app"></div>
<template id="tpl">
<div ref="wrapper" @touchstart.prevent="onStart" @touchmove.prevent="onMove" @touchend.prevent="onEnd" @touchcancel.prevent="onEnd" @transitionend="onTransitionEnd">
<ul ref="scroller" :style="scrollerStyle">
<li v-for="item in list">{{item}}</li>
</ul>
</div>
</template>
<script> new Vue({ el: '#app', template: '#tpl', computed: { list() {}, scrollerStyle() { return { 'transform': `translate3d(0, ${this.offsetY}px, 0)`, 'transition-duration': `${this.duration}ms`, 'transition-timing-function': this.bezier, }; }, }, data() { return { minY: 0, maxY: 0, wrapperHeight: 0, duration: 0, bezier: 'linear', pointY: 0, // touchStart 手势 y 坐标 startY: 0, // touchStart 元素 y 偏移值 offsetY: 0, // 元素实时 y 偏移值 startTime: 0, // 惯性滑动范围内的 startTime momentumStartY: 0, // 惯性滑动范围内的 startY momentumTimeThreshold: 300, // 惯性滑动的启动 时间阈值 momentumYThreshold: 15, // 惯性滑动的启动 距离阈值 isStarted: false, // start锁 }; }, mounted() { this.$nextTick(() => { this.wrapperHeight = this.$refs.wrapper.getBoundingClientRect().height; this.minY = this.wrapperHeight - this.$refs.scroller.getBoundingClientRect().height; }); }, methods: { onStart(e) { const point = e.touches ? e.touches[0] : e; this.isStarted = true; this.duration = 0; this.stop(); this.pointY = point.pageY; this.momentumStartY = this.startY = this.offsetY; this.startTime = new Date().getTime(); }, onMove(e) { if (!this.isStarted) return; const point = e.touches ? e.touches[0] : e; const deltaY = point.pageY - this.pointY; this.offsetY = Math.round(this.startY + deltaY); const now = new Date().getTime(); // 记录在触发惯性滑动条件下的偏移值和时间 if (now - this.startTime > this.momentumTimeThreshold) { this.momentumStartY = this.offsetY; this.startTime = now; } }, onEnd(e) { if (!this.isStarted) return; this.isStarted = false; if (this.isNeedReset()) return; const absDeltaY = Math.abs(this.offsetY - this.momentumStartY); const duration = new Date().getTime() - this.startTime; // 启动惯性滑动 if (duration < this.momentumTimeThreshold && absDeltaY > this.momentumYThreshold) { const momentum = this.momentum(this.offsetY, this.momentumStartY, duration); this.offsetY = Math.round(momentum.destination); this.duration = momentum.duration; this.bezier = momentum.bezier; } }, onTransitionEnd() { this.isNeedReset(); }, momentum(current, start, duration) { const durationMap = { 'noBounce': 2500, 'weekBounce': 800, 'strongBounce': 400, }; const bezierMap = { 'noBounce': 'cubic-bezier(.17, .89, .45, 1)', 'weekBounce': 'cubic-bezier(.25, .46, .45, .94)', 'strongBounce': 'cubic-bezier(.25, .46, .45, .94)', }; let type = 'noBounce'; // 惯性滑动加速度 const deceleration = 0.003; // 回弹阻力 const bounceRate = 10; // 强弱回弹的分割值 const bounceThreshold = 300; // 回弹的最大限度 const maxOverflowY = this.wrapperHeight / 6; let overflowY; const distance = current - start; const speed = 2 * Math.abs(distance) / duration; let destination = current + speed / deceleration * (distance < 0 ? -1 : 1); if (destination < this.minY) { overflowY = this.minY - destination; type = overflowY > bounceThreshold ? 'strongBounce' : 'weekBounce'; destination = Math.max(this.minY - maxOverflowY, this.minY - overflowY / bounceRate); } else if (destination > this.maxY) { overflowY = destination - this.maxY; type = overflowY > bounceThreshold ? 'strongBounce' : 'weekBounce'; destination = Math.min(this.maxY + maxOverflowY, this.maxY + overflowY / bounceRate); } return { destination, duration: durationMap[type], bezier: bezierMap[type], }; }, // 超出边界时须要重置位置 isNeedReset() { let offsetY; if (this.offsetY < this.minY) { offsetY = this.minY; } else if (this.offsetY > this.maxY) { offsetY = this.maxY; } if (typeof offsetY !== 'undefined') { this.offsetY = offsetY; this.duration = 500; this.bezier = 'cubic-bezier(.165, .84, .44, 1)'; return true; } return false; }, // 中止滚动 stop() { const matrix = window.getComputedStyle(this.$refs.scroller).getPropertyValue('transform'); this.offsetY = Math.round(+matrix.split(')')[0].split(', ')[5]); }, }, }); </script>
</body>
</html>
复制代码
欢迎关注凹凸实验室博客:aotu.io
或者关注凹凸实验室公众号(AOTULabs),不定时推送文章: