在移动端 H5 中, 时间选择器 (date-picker), 省市区选择器(area-picker) 等组件经常会使用这样的交互效果:
微信原生 date-picker 效果
这个 gif 是在[微信钱包 - 账单] 中录制的 iOS 原生时间选择器. 可见, 当用户手指在选择器上先是滑动再从屏幕上移开, 内容会继续保持一段时间的滚动效果, 并且滚动的速度和持续的时间是与滑动手势的强烈程度成正比. 这种交互思路源于 iOS 系统原生元素的滚动回弹(momentum-based scrolling), 来看 H5 的一个普通列表在 iOS 上的滚动表现:
社区上大部分的移动端组件库的选择器组件都采取了这种交互方式, 看看效果:
weui: picker
weui 的选择器实现了惯性滑动, 但滑动动画结束得有点突兀, 效果一般.
vant: picker
vant 的选择器压根没有做惯性滑动, 当手指从屏幕上移开后, 选择器的滑动会立刻停止. 可见这样的交互体验是比较差的.
接下来我会从设计层面剖析和模拟惯性滑动的交互效果.
物理学应用
不难想象, 惯性滑动非常贴合现实生活中的一些场景, 如汽车刹车等. 除此之外, 与物理力学中的滑块模型也十分相似, 由此我会参考滑块模型来剖析惯性滑动的全过程.
惯性 来源于物理学中的惯性定律(即 牛顿第一定律): 一切物体在没有受到力的作用的时候, 运动状态不会发生改变, 物体所拥有的这种性质就被称为惯性. 我们不妨把惯性滑动模拟成滑动滑块然后释放的过程(以下讨论中用户滑动的目标皆模拟成 滑块), 主要划分为两个阶段:
用户滑动滑块使其从静止开始做加速运动;
用户释放滑块使其只在摩擦力的作用下继续滑动, 直至静止;
惯性滑动距离
描述滑块的惯性滑动, 首先需要求出滑动的距离. 在上述二阶段中, 滑块受摩擦力 作 匀减速直线运动. 假设滑动距离为, 初速度为, 末速度为. 根据位移公式
加速度公式
可以算出惯性滑动距离
由于匀减速运动的加速度为负, 不妨设一个加速度常量, 使其满足, 那么
这里 为正数. 也就是说, 我们只需要求出初始速度即可.
实际计算时,会导致计算出的惯性滑动距离过大, 因此公式调整为.
关注第一个阶段, 假设用户滑动滑块的距离为 , 滑动的持续时间是, 那么二阶段的初速度 可以根据位移公式求得
综上, 求惯性滑动的距离我们需要记录用户滑动滑块的 距离 和 持续时间, 并设置一个合理的 加速度常量.
经测试, 加速度常量的合适值为 .
注意, 这里的距离和持续时间并不是用户滑动滑块的总距离和时长, 而是触发惯性滑动范围内的距离和时长, 详见[惯性滑动的启动条件] .
惯性滑动速度曲线
针对二阶段的匀减速直线运动, 时间段 产生的位移差, 其中. 也就是说时间越往后, 同等时间间距下通过的位移越来越小, 也就是动画的推进速度越来越慢.
这与 CSS3 transition-timing-function 中的 ease-out 速度曲线相吻合, ease-out (即 cubic-bezier(0, 0, .58, 1))的贝塞尔曲线为
上图来自 在线绘制贝塞尔曲线网站. 图表中的纵坐标是指 动画推进的进程; 横坐标是指 时间; 原点坐标为 (0, 0), 终点坐标为 (1, 1), 假设动画持续时间为 2 秒,(1, 1)坐标点则代表离动画开始 2 秒时动画执行完毕(100%). 根据图表可以得出, 时间越往后动画进程的推进速度越慢, 符合匀减速直线运动的特性.
然而这样的速度曲线过于线性平滑, 减速效果不明显. 我们基于 iOS 滚动回弹的效果, 调整贝塞尔曲线的参数为 cubic-bezier(.17, .89, .45, 1).
回弹
滑块滑动不是无边界的, 我们来考虑这样的场景: 当滑块向下滑动, 其顶部正要接触容器上边界时速度还没有降到, 此时如果让滑块瞬间停止运动, 这样的交互效果是不理想的.
我们可以把上边界想象成一条与滑块紧密贴合的固定弹簧, 当滑块到达临界点而速度还没有降到 时, 滑块会继续滑动并拉动弹簧使其往下形变, 同时会受到弹簧的反拉力作减速运动(动能转化为内能); 当滑块速度降为, 此时弹簧的形变量最大, 由于弹性特质弹簧会恢复原状(内能转化成动能), 从而拉动滑块反向运动.
回弹过程也可以分为两个阶段:
滑块拉动弹簧作变减速运动. 此阶段滑块受摩擦力 和越来越大的弹簧反拉力 共同作用, 加速度越来越大, 所以速度降为 的时间非常短;
弹簧恢复原状, 拉动滑块作先变加速后变减速运动. 此阶段滑块受到的摩擦力 和越来越小的弹簧拉力 相互抵消, 刚开始, 滑块作加速度越来越大的变减速运动, 直至静止. 这里为了交互效果我们可以营造一个理想状态: 滑块静止时弹簧刚好恢复形变.
回弹距离
根据上述分析, 回弹的第一阶段作加速度越来越大的变减速直线运动, 设此阶段的初速度为 , 可以与 建立以下关系
那么回弹距离为
微积分都来了, 简直没法算好吧...
我们可以根据运动模型来简化 的计算, 由于该阶段的加速度大于 非回弹惯性滑动 的加速度, 设 非回弹惯性滑动 的总距离为, 那么
所以可以设置一个合理的常量, 使其满足
经测试, 常量 的合理取值为 10.
回弹速度曲线
整个触发回弹的惯性滑动模型包括三个运动阶段:
然而把 阶段 a 和 阶段 b 描绘成 CSS 动画是有一定复杂度和风险的:
阶段 b 中的变减速运动难以描绘;
两个阶段运动方向相同但动画速度曲线不连贯, 容易造成用户体验的断层;
出于简化的考虑, 可以将 阶段 a,b 合并为一个运动阶段:
对于合并后的 阶段 a 末段, 由于反向加速度越来越大, 因此滑块减速的效率会比 非回弹惯性滑动 同期更大, 对应的贝塞尔曲线末段也会更陡, 参数调整为 cubic-bezier(.25, .46, .45, .94).
在 阶段 b 中, 滑块先变加速后变减速, 尝试 ease-in-out 的动画曲线:
可以看出, 由于 阶段 b 初始的 ease-in 曲线使 阶段 a,b 的衔接段稍有停留, 效果体验一般. 所以我们选择只描绘变减速运动这一段, 调整贝塞尔曲线为 cubic-bezier(.165, .84, .44, 1).
由于 mp4 转 gif 格式会掉帧, 所以示例效果看起来会有点卡顿, 建议直接体验 demo.
动画时长
PS: 以下取值都是基于对 iOS 滚动回弹实例的测量.
一次惯性滑动可能会出现两种情况:
没有触发回弹
滑动动画的持续时间为 2500ms.
触发回弹
阶段 a 中, 当 大于某个阈值时, 为 强回弹, 动画时长设为 400ms, 反之为 弱回弹, 时长设为 800ms;
阶段 b 持续时间为 500ms;
惯性滑动启停
启动条件
惯性滑动的启动需要有足够的动量. 我们可以简单地认为, 当用户滑动的距离足够大 (大于 15px) 和持续时间足够短 (小于 300ms) 时, 即可产生惯性滑动. 也就是说, 最后一次 touchmove 事件触发的时间和 touchend 事件触发的时间间隔小于 300ms, 且两者产生的距离差大于 15px 时认为启动惯性滑动.
暂停时机
当惯性滑动未结束(包括处于回弹过程), 用户再次触碰滑块时会暂停滑块的运动. 原理上是通过 getComputedStyle 和 getPropertyValue 方法获取当前的 transform: matrix() 矩阵值, 抽离出水平 y 轴偏移量后重新调整 translate 的位置.
完整代码
demo 基于 vuejs 实现, 预览地址: https://jsfiddle.net/JunreyCen/xcLvbjg8/
- <HTML>
- <head>
- <meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=0">
- <style>
- body, ul {
- margin: 0;
- padding: 0;
- }
- ul {
- list-style: none;
- }
- .wrapper {
- position: absolute;
- top: 50%;
- left: 0;
- right: 0;
- margin: 0 auto;
- height: 80%;
- width: 80%;
- max-width: 300px;
- max-height: 500px;
- border: 1px solid #000;
- transform: translateY(-50%);
- overflow: hidden;
- }
- .list {
- background-color: #70f3b7;
- }
- .list-item {
- height: 40px;
- line-height: 40px;
- width: 100%;
- text-align: center;
- border-bottom: 1px solid #ccc;
- }
- </style>
- </head>
- <body>
- <div id="app"></div>
- <template id="tpl">
- <div
- class="wrapper"
- ref="wrapper"
- @touchstart.prevent="onStart"
- @touchmove.prevent="onMove"
- @touchend.prevent="onEnd"
- @touchcancel.prevent="onEnd"
- @mousedown.prevent="onStart"
- @mousemove.prevent="onMove"
- @mouseup.prevent="onEnd"
- @mousecancel.prevent="onEnd"
- @mouseleave.prevent="onEnd"
- @transitionend="onTransitionEnd">
- <ul
- class="list"
- ref="scroller"
- :style="scrollerStyle">
- <li
- class="list-item"
- v-for="item in list">
- {{item}}
- </li>
- </ul>
- </div>
- </template>
- <script src="https://cdn.jsdelivr.net/npm/vue"></script>
- <script>
- new Vue({
- el: '#app',
- template: '#tpl',
- computed: {
- list() {
- const list = [];
- for (let i = 0; i <100; i++) {
- list.push(i);
- }
- return list;
- },
- scrollerStyle() {
- return {
- 'transform': `translate3d(0, ${this.offsetY}px, 0)`,
- 'transition-duration': `${this.duration}ms`,
- 'transition-timing-function': this.bezier,
- };
- },
- },
- data() {
- return {
- wrapper: null,
- scroller: null,
- minY: 0,
- maxY: 0,
- wrapperHeight: 0,
- offsetY: 0,
- duration: 0,
- bezier: 'linear',
- startY: 0,
- pointY: 0,
- startTime: 0, // 惯性滑动范围内的 startTime
- momentumStartY: 0, // 惯性滑动范围内的 startY
- momentumTimeThreshold: 300, // 惯性滑动的启动 时间阈值
- momentumYThreshold: 15, // 惯性滑动的启动 距离阈值
- isStarted: false, // start 锁
- };
- },
- mounted() {
- this.$nextTick(() => {
- this.wrapper = this.$refs.wrapper;
- this.scroller = this.$refs.scroller;
- const { height: wrapperHeight } = this.wrapper.getBoundingClientRect();
- const { height: scrollHeight } = this.scroller.getBoundingClientRect();
- this.wrapperHeight = wrapperHeight;
- this.minY = wrapperHeight - scrollHeight;
- });
- },
- 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;
- // 浮点数坐标会影响渲染速度
- let offsetY = Math.round(this.startY + deltaY);
- // 超出边界时增加阻力
- if (offsetY <this.minY || offsetY> this.maxY) {
- offsetY = Math.round(this.startY + deltaY / 3);
- }
- this.offsetY = offsetY;
- 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 = 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() {
- // 获取当前 translate 的位置
- const matrix = Windows.getComputedStyle(this.scroller).getPropertyValue('transform');
- this.offsetY = +matrix.split(')')[0].split(',')[5];
- },
- },
- });
- </script>
- </body>
- </HTML>
- Reference
- weui-picker
- better-scroll
来源: http://www.jianshu.com/p/8015c54a1500