如何实现一个滑动消除组件

如何用原生实现一个滑动消除卡片的组件

今天看到chrome github 实现的一个通过左右滑动清除子卡片的的组件。感觉有点意思,学习一下

源地址在这里
https://github.com/GoogleChrome/ui-element-samples/tree/gh-pages/swipeable-cards

需求分析

这个组件的功能很单一

  • 接受一组元素,使之可以通过鼠标或是触控左右滑动元素
  • 当左右滑动到达一定程度以后则删除该列表元素

这个组件的一个难点在于如何让元素跟随鼠标或是触控左右滑动。
关于这一点,我们可以将整个过程分为

开始滑动->滑动中->滑动结束

同时为了滑动过程的流畅,我们不在滑动中触发响应的位移动画,
我们可以单独抽出一个 update 函数负责函数动画的绘制,
而只要 requestAnimationFrame 函数循环调用在每一帧中检查是否有需要位移,有则更新利用 update 响应的位移位置到 css 动画中

开始滑动

这一阶段我们需要捕获 mousedown 跟 touchstart 事件,通过事件的target确定被拖动的元素
并记录 target 元素相关数据,比如说我们最后需要判断位移是否“过了”该触发删除的点所以需要一个开始位移的位置,
另一方面就是拖放过程中的位移记录
还有就是设置一下 css 动画相关的样式,触发位移动画的一些设置

滑动中

这一阶段对应的是 mousemove 与touchmove 事件
这个阶段最简单,只需要不断更新位移的位置就好了,滑动的效果由 update 自己通过 requsetnimationFrame 不断去调用检查就好

滑动结束

这一阶段对应的是 mouseup 与touchend 事件
这个时候需要做的事情会多一点点,需要稍微计算一下我们在开始滑动的时候保存的位置,判断当前位置是否已经过了这个某个点,可以删除元素还是令其归位

复位与惯性

为了是左右滑动卡片过程中更加平滑,需要为滑动卡片结束后的两个状态,做处理。

惯性

当需要移除子元素的时候,增加一个惯性的继续移动的动画效果。
为了实现一个惯性的效果

首先,我们知道了当前位移了 screenX,这个是时候通过 screenX 的正负判断出位移的方向。
然后 google 的做法是增加一个 target 元素的宽的目标位移距离,然后

在更新动画的时候,我们根据 targetX 以每次 (screenX-targetX)/4 的变化趋势增加 screenX 达到一个平缓增加的过程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
if (Math.abs(screenX) > threshold) {
// 若偏移的位置已经大于边界值,则通过 screenX 判断到底是左划还是右划
this.targetX = (screenX > 0) ?
this.targetBCR.width :
-this.targetBCR.width;
}
......
if (this.draggingCard) {
this.screenX = this.currentX - this.startX;
} else {
// 如果不在拖动中,那么就有可能是惯性移动,我们根据targetX 以每次1/4 的变化趋势增加 screenX
this.screenX += (this.targetX - this.screenX) / 4;
}

复位

当未到边界的时候,增加一个归位的动画效果,这里实际上跟惯性是同样的,只不过他位移 targetX 应当是 0

如何计算透明度

在滑动过程中,为了表现出一种简便的过程,我们可以增加一个滑动元素的透明度的改变更加直观的显示出,当前处于什么什么状态。
问题在于我们的滑动动画是不是一个线性的过程,因为我们为了动画效果平滑,应用了贝塞尔曲线。
因此,我们需要对透明度的变化做出类似的平滑变化,而不是一个线性的变化。

这里的做法是,将 (位移的距离/元素的宽)^3 通过这样一个简单的三次方程来拟合贝塞尔曲线,

1
2
3
4
5
6
7
const normalizedDragDistance =
(Math.abs(this.screenX) / this.targetBCR.width);
const opacity = 1 - Math.pow(normalizedDragDistance, 3);
// 更新css动画样式
this.target.style.transform = `translateX(${this.screenX}px)`;
this.target.style.opacity = opacity;

代码注释

1
2
3
4
5
6
7
8
9
<div class="card-container">
<div class="card">Das Surma</div>
<div class="card">Aerotwist</div>
<div class="card">Kinlanimus Maximus</div>
<div class="card">Addyoooooooooo</div>
<div class="card">Gaunty McGaunty Gaunt</div>
<div class="card">Jack Archibungle</div>
<div class="card">Sam "The Dutts" Dutton</div>
</div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
class Cards {
constructor ({cards=document.querySelectorAll('.card')}) {
// 需要控制为元素列表
this.cards = Array.from(cards);
// 分别对应每个阶段的事件响应函数,以及更新动画的 update 函数
// 这里会 需要 bind 绑定是为避免析构之后,this 指向不清晰的问题
this.onStart = this.onStart.bind(this);
this.onMove = this.onMove.bind(this);
this.onEnd = this.onEnd.bind(this);
this.update = this.update.bind(this);
// 保持 target 元素的getBoundingClientRect信息
this.targetBCR = null;
// 被滑动元素
this.target = null;
// 记录滑动开始元素所在为位置
this.startX = 0;
// 记录当前鼠标或触控滑动位移的位置
this.currentX = 0;
// startX 与 currentX 的差值
this.screenX = 0;
// 目标位置,当结束滑动的时候,判断是返回原位还是向外在位移一些
this.targetX = 0;
// 是否处于滑动中
this.draggingCard = false;
// 绑定事件
this.addEventListeners();
// 循环触发 update 更新动画
requestAnimationFrame(this.update);
}
addEventListeners () {
// 绑定每个阶段的事件
document.addEventListener('touchstart', this.onStart);
document.addEventListener('touchmove', this.onMove);
document.addEventListener('touchend', this.onEnd);
document.addEventListener('mousedown', this.onStart);
document.addEventListener('mousemove', this.onMove);
document.addEventListener('mouseup', this.onEnd);
}
// 开始滑动事件
onStart (evt) {
// 如果 target 不为空则意味已经有滑动中的元素,应当退出
if (this.target)
return;
// 判断是否是列表元素
if (!evt.target.classList.contains('card'))
return;
// 获取 target 元素
this.target = evt.target;
// 获取 target 元素的 getBoundingClientRect 信息
this.targetBCR = this.target.getBoundingClientRect();
// 记录 开始位置 startX 与当前位置 currentX ,这个时候两者都是事件的pageX或者touches[0].pageX
this.startX = evt.pageX || evt.touches[0].pageX;
this.currentX = this.startX;
// 将状态标记为滑动中
this.draggingCard = true;
// 通过 willchange 通知浏览器我们可能会用到 transform 属性
this.target.style.willChange = 'transform';
// 取消事件的默认行为
evt.preventDefault();
}
onMove (evt) {
if (!this.target)
return;
// 不断更新当前的位置
this.currentX = evt.pageX || evt.touches[0].pageX;
}
onEnd (evt) {
if (!this.target)
return;
// 设置 targetX
this.targetX = 0;
// 计算出当前位置与开始位置的差
let screenX = this.currentX - this.startX;
// 是否删除元素的边界值
const threshold = this.targetBCR.width * 0.35;
if (Math.abs(screenX) > threshold) {
// 若偏移的位置已经大于边界值,则通过 screenX 判断到底是左划还是右划
this.targetX = (screenX > 0) ?
this.targetBCR.width :
-this.targetBCR.width;
}
// 重置状态为未滑动
this.draggingCard = false;
}
update () {
// 递归调用,检查是否需要更新动画
requestAnimationFrame(this.update);
if (!this.target)
return;
// 如果卡片元素处于被拖拉的状态,那么需要偏移的位置就是当前事件x轴的位置到起始位置的距离
if (this.draggingCard) {
this.screenX = this.currentX - this.startX;
} else {
// 如果不在拖动中,那么就有可能是归位,或是再向当前方向移动多一点,表现出惯性的样子
this.screenX += (this.targetX - this.screenX) / 4;
}
// 对拖动的距离进行归一化处理,以求出相应的透明度,表现出淡出的效果
// opacity = 1 - (偏移距离/元素宽度)^3
const normalizedDragDistance =
(Math.abs(this.screenX) / this.targetBCR.width);
const opacity = 1 - Math.pow(normalizedDragDistance, 3);
// 更新css动画样式
this.target.style.transform = `translateX(${this.screenX}px)`;
this.target.style.opacity = opacity;
// User has finished dragging.
// 如果还在拖动中,则到这里结束了
if (this.draggingCard)
return;
// 处理拖动结束时候的状态
// 根据透明度与,偏移距离判断是消失还是归位
const isNearlyAtStart = (Math.abs(this.screenX) < 0.1);
const isNearlyInvisible = (opacity < 0.01);
// If the card is nearly gone.
// 如果元素应当消失
if (isNearlyInvisible) {
// Bail if there's no target or it's not attached to a parent anymore.
if (!this.target || !this.target.parentNode)
return;
// 从dom树中删除该元素
this.target.parentNode.removeChild(this.target);
// 从数组中删除
const targetIndex = this.cards.indexOf(this.target);
this.cards.splice(targetIndex, 1);
// Slide all the other cards.
// 更新其他元素的位置
this.animateOtherCardsIntoPosition(targetIndex);
} else if (isNearlyAtStart) {
// 如果需要归位,则直接重置 target
this.resetTarget();
}
}
// 处理删除元素以后,其他元素位置变化的动画
animateOtherCardsIntoPosition (startIndex) {
// If removed card was the last one, there is nothing to animate.
// Remove the target.
// 如果被移除的元素是最后一个,那就不影响其他元素,直接重置 target 元素
if (startIndex === this.cards.length) {
this.resetTarget();
return;
}
// 在动画结束以后,清除样式
const onAnimationComplete = evt => {
const card = evt.target;
card.removeEventListener('transitionend', onAnimationComplete);
card.style.transition = '';
card.style.transform = '';
this.resetTarget();
};
// Set up all the card animations.
// 设置被删除元素,之后的所有元素的向上偏移动画
for (let i = startIndex; i < this.cards.length; i++) {
const card = this.cards[i];
// Move the card down then slide it up.
// 由于此时target 元素已经被移除,真正的动画效果不是直接向上,而是先腾出原先 元素的位置,即向下移动一个元素的高加间距的距离
card.style.transform = `translateY(${this.targetBCR.height + 20}px)`;
card.addEventListener('transitionend', onAnimationComplete);
}
// Now init them.
requestAnimationFrame(_ => {
for (let i = startIndex; i < this.cards.length; i++) {
const card = this.cards[i];
// Move the card down then slide it up, with delay according to "distance"
// 等到下一帧所有元素都下移以后,为卡片元素设置动画运动曲线与延时,并清空 transform ,这样接着才会产生动画向上偏移的动画
card.style.transition = `transform 150ms cubic-bezier(0,0,0.31,1) ${i*50}ms`;
card.style.transform = '';
}
});
}
// 重置元素
resetTarget () {
if (!this.target)
return;
this.target.style.willChange = 'initial';
this.target.style.transform = 'none';
this.target = null;
}
}
window.addEventListener('load', () => new Cards());