鉴于在阶段 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
CSS 动效时长我们对 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></div> <template> <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> 参考资料weui-picker
better-scroll
欢迎关注凹凸实验室博客:aotu.io
或者关注凹凸实验室公众号(AOTULabs),不定时推送文章: