我们在 ios 应用(特别是浏览器)中经常看到这样的 “橡皮筋” 效果:当页面滚动到边缘时若继续拖动,其位移变化量和拖动距离成反比;就像橡皮筋一样,拉动的距离越大则继续发生形变的难度越大,也就是所受到的阻尼力越大:
接下来我会基于 vue
和 移动端 Touch
事件实现这样的 “橡皮筋” 效果。
阻尼曲线
以横坐标为 拖动距离,纵坐标为 实际位移 建立坐标轴。如此,符合 “橡皮筋” 效果的数学函数模型并不难找,我在这里提供两个基础函数模型,对数函数 和 幂函数:
各自对应的函数图像趋势大致如下:
为了满足 H5 向下拖动的实际场景,我们需要对函数体进行微调。此外,还需要设置一个 容器高度值 作为被拖动元素的位移最大值的参考。那么函数调整为:
不妨设,绘制函数图像:
可见曲线差距不大,我们选择基于幂函数来制作 demo:
如 gif 图所示,在刚开始往下拖动的阶段,元素发生了较大幅度的跳动,这是由于该阶段的函数值,也就是元素的位移甚至比手指拖动的距离还要大,从而产生不合理的 “跳动”。
使,借助 WolframAlpha计算引擎 求解得 ,因此在的区间内,都是比大的。
换句话说,我们需要 降低函数图像曲线首段的陡度,使元素随手指拖动的变化幅度更加平缓。由于数学水平有限,我在这里仅提供一种比较麻烦的方式 —— 分段线性函数。
以 ios 原生的 “橡皮筋” 效果为参考,经过大量的测试,我刻画出了一套较为合理的分段线性函数:
同样地使,绘制函数图像:
demo 实际效果:
函数效率
对于 JS 引擎来说,简单的线性四则运算要比复杂的幂函数、对数函数等运算耗时更短,性能损耗更低。但是在拖动阻尼的场景下,由于实现分段线性函数需要利用循环和声明更多的临时变量,代码性能往往比单单调用 Math.pow()
或 Math.log()
方法要低很多。
我对上述中的三种函数模型都分别提供了代码实现及 测试用例:
linear: 分段线性函数,log: 对数函数,pow: 幂函数性能差距惨不忍睹…
那么,我们能否找出一个合适的数学表达式,既能符合或近似于上面提出的分段线性函数的图像曲线,又能降低性能损耗呢?
曲线拟合
在分段线性函数的图像上取样关键点:
x | 0 | 500 | 1000 | 1500 | 2500 | 6000 | 8000 | 10000 | 12000 |
---|---|---|---|---|---|---|---|---|---|
y | 0 | 90 | 160 | 210 | 260 | 347.5 | 357.5 | 367.5 | 377.5 |
通过 在线曲线拟合神器,使用 四参数方程模型 拟合曲线,得
如果有条件的话,这里建议使用 matlab 做曲线拟合。
舍去,其他常数四舍五入,并化简表达式,得
通过 Wolfram Cloud平台 绘制该表达式在范围的图像曲线:
Prefect!
然而这个表达式是在的条件下的,我们需要还原值,最终表达式为
瞧瞧 性能表现 :
curve: 拟合函数,linear: 分段线性函数,log: 对数函数,pow: 幂函数多点触控
在元素拖动的交互场景里,实现多点触控其实非常简单,主要围绕 TouchEvent
事件中的
-
TouchEvent.touches
对象
包含所有当前接触触摸平面的触点的Touch
对象; -
TouchEvent.changedTouches
对象
包含从上一次触摸事件到此次事件过程中状态发生改变的触点的Touch
对象。譬如某个触点从触摸平面中释放时,touchend
事件中的changedTouches
对象就会包含该触点;
处理流程如下:
- 当有新触点接触平面时,
touchstart
事件被触发,以Touch.identifier
为id
缓存触点起始坐标; - 触点移动时,
touchmove
事件被触发,根据id
计算各个触点当前位置与起始坐标的偏移值并求和; - 当有触点从平面中释放时,
touchend
事件被触发,记录该触点所“贡献”的偏移值,若所有触点都已释放则重置;
代码实现
提供的 demo 仅支持在移动端预览:https://codepen.io/JunreyCen/pen/LoryNp
<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;
transition-timing-function: cubic-bezier(.165, .84, .44, 1);
}
.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">
<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`,
};
},
},
data() {
return {
wrapper: null,
scroller: null,
minY: 0,
maxY: 0,
wrapperHeight: 0,
offsetY: 0,
duration: 0,
pos: {},
cacheOffsetY: 0,
};
},
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) {
this.duration = 0;
this.stop();
// 是否为第一个触点,若是则需要重置 cacheOffsetY 值
let isFirstTouch = true;
Array.from(e.touches).forEach(touch => {
const id = touch.identifier;
if (!this.pos[id]) {
this.pos[id] = touch.pageY;
return;
}
isFirstTouch = false;
});
if (isFirstTouch) {
this.cacheOffsetY = this.offsetY;
}
},
onMove(e) {
let offset = 0;
Array.from(e.touches).forEach(touch => {
const id = touch.identifier;
if (this.pos[id]) {
offset += Math.round(touch.pageY - this.pos[id]);
}
});
offset = this.cacheOffsetY + offset;
// 超出边界时增加阻尼效果
if (offset < this.minY || offset > this.maxY) {
this.offsetY = this.damping(offset, this.wrapperHeight);
} else {
this.offsetY = offset;
}
},
onEnd(e) {
Array.from(e.changedTouches).forEach(touch => {
const id = touch.identifier;
if (this.pos[id]) {
this.cacheOffsetY += Math.round(touch.pageY - this.pos[id]);
}
});
// 当所有触点都离开平面
if (!e.touches.length) {
this.cacheOffsetY = 0;
this.pos = {};
this.resetPosition();
}
},
stop() {
// 获取当前 translate 的位置
const matrix = window.getComputedStyle(this.scroller).getPropertyValue('transform');
this.offsetY = Math.round(+matrix.split(')')[0].split(', ')[5]);
},
// 超出边界时重置位置
resetPosition() {
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;
}
},
// 阻尼函数
damping(x, max) {
let y = Math.abs(x);
y = 0.82231 * max / (1 + 4338.47 / Math.pow(y, 1.14791));
return Math.round(x < 0 ? -y : y);
},
},
});
</script>
</body>
</html>
网友评论