在移动端 H5 中,时间选择器(date-picker
)、省市区选择器(area-picker
)等组件经常会使用这样的交互效果:
这个 gif 是在【微信钱包 - 账单】中录制的 ios 原生时间选择器。可见,当用户手指在选择器上先是滑动再从屏幕上移开,内容会继续保持一段时间的滚动效果,并且滚动的速度和持续的时间是与滑动手势的强烈程度成正比。这种交互思路源于 ios 系统原生元素的滚动回弹(momentum-based scrolling
),来看 H5 的一个普通列表在 ios 上的滚动表现:
社区上大部分的移动端组件库的选择器组件都采取了这种交互方式,看看效果:
weui 的选择器实现了惯性滑动,但滑动动画结束得有点突兀,效果一般。
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 = window.getComputedStyle(this.scroller).getPropertyValue('transform');
this.offsetY = +matrix.split(')')[0].split(', ')[5];
},
},
});
</script>
</body>
</html>
网友评论