用 JavaScript 实现手势库 — 手势动画应用前端组件化
Posted 三钻
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了用 JavaScript 实现手势库 — 手势动画应用前端组件化相关的知识,希望对你有一定的参考价值。
前端《组件化系列》目录
- 「一」用 JSX 建立组件 Parser(解析器)
- 「二」使用 JSX 建立 Markup 组件风格
- 「三」用 JSX 实现 Carousel 轮播组件
- 「四」用 JavaScript 实现时间轴与动画
- 「五」用 JavaScript 实现三次贝塞尔动画库 - 前端组件化
- 「六」用 JavaScript 实现手势库 - 实现监听逻辑
- 「七」用 JavaScript 实现手势库 — 手势逻辑
- 「八」用 JavaScript 实现手势库 — 支持多键触发
- 「九」用 JavaScript 实现手势库 — 事件派发与 Flick 事件
- 「十」用 JavaScript 实现手势库 — 封装手势库
- 「十一」用 JavaScript 实现手势库 — 手势动画应用 《 本期 》
- … 待续 …
通过上几篇文章,我们对动画已经有了更深入的了解。这里我们就回到我们之前写好的 Carousel 组件。
如果我们回头去看看我们之前写的 Carousel 代码,我们会发现这里的代码我们真的是写的太粗躁了。
我们的拖拽功能中的点击和拖动都是分不清楚的。然后我们也是没有做 flick
的这种判断。动画的时间也是用 CSS 动画来完成的。
更不用说,这个拖拽动画是否可以暂停、继续这样的逻辑。
那么这里我们就把我们上几篇在文章中实现的 Animation(动画库)和 Gesture(手势库)应用进来,从而实现一个完善的 Carousel(轮播组件)。
同学们注意: 只看这篇文章是无法看懂的,因为这篇文章的内容,都是基于之前的《轮播组件》、《实现动画库》、《实现手势库》三篇文章的内容。还没有学习这三篇文章的,请先前往学习再回来看这里哦~
Gesture 库应用
我们之前写的 Gesture, 是一个单独可用的手势库。但是如果它是作为一个正式的一个 Library,我们还是需要对它进行一些单元测试,或者一些代码 lint 风格的检查等等。
最后,它是可以单独作为一个 npm 模块去安装使用。不过我们目前的 Gesture 库还没有这些基础设施。所以我们就直接拿过来用了。
不过很多公司确实也是这样用的,写一个库,然后靠着业务去提升它的稳定性。这个也不能说是错误。在工作中也有很多库是通过 end to end 的测试来覆盖它的功能的。
从复杂性来讲,animation、gesture 和 carousel 都有一定的复杂性。当我们做了架构拆分之后,我们分别独立管理了这三部分的复杂性。否则如果这三部分融在一起的话,绝对会要了我们的命的。
讲那么多,我们一起正式进入改装我们原来的 carousel 组件吧。
重构 render()
首先我们需要注释掉原来 render()
里面的逻辑:
render() {
this.root = document.createElement('div');
this.root.classList.add('carousel');
for (let picture of this.attributes.src) {
let child = document.createElement('div');
child.style.backgroundImage = `url('${picture}')`;
this.root.appendChild(child);
}
let children = this.root.children;
let position = 0;
return this.root;
/* ... 保留到这里 ... */
}
然后我们需要重新使用我们 gesture 库中的 enableGesture()
来启动所有手势操作的事件监听。
要使用这个函数,我们首先需要引入我们的 gesture 库:
import { enableGesture } from './gesture.js';
接着我们就可以在 this.root
上去 enableGesture,在 let position = 0
,后面加入一下代码:
enableGesture(this.root);
/* 我们加入这个监听来看看我们的手势事件是否启动了 */
this.root.addEventListener("pan", event => {
console.log(event.clientX);
})
运行起来之后,我们就发现在网页端和移动端的 pan
事件都是被正常监控了。
上图中,整数的就是网页端的输出,而的有较多小数点的就是移动端的输出。
虽然事件是正常监听了,但是我们拖拽的时候,图片并没有任何移动的。所以我们还重新加入图片移动的逻辑。这段逻辑就在我们之前 mousemove
的事件之中。
实现 pan 事件
那么我们就来重新实现一下这部分代码。
this.root.addEventListener("pan", event => {
// 之前的 startX 我们可以直接在 event 中取得
// 因为我们在实现 gesture 的时候就都挂载到 event 中了
let x = event.clientX - event.startX;
let current = position - (x - (x % 500)) / 500;
/* 剩余这些代码都可以直接拿过来用就好了 */
for (let offset of [-1, 0, 1]) {
let pos = current + offset;
// 计算图片所在 index
pos = (pos + children.length) % children.length;
children[pos].style.transition = 'none';
children[pos].style.transform = `translateX(${-pos * 500 + offset * 500 + (x % 500)}px)`;
}
})
这样我们就实现了 move(拖拽鼠标移动)这部分的逻辑了。我们先来看看是不是已经可以拖动了。
Wonderful,我们非常方便就实现了拖拽移动图片了。但是如果我们拖动到超出三张以上的图片的时候,console 中就会抛出一下错误:
这又是为什么呢?我们先来看看报错的位置。
首先是 “超出 3 张图片时”,第二是 children 中没有当前 pos
的元素。根据这两个线索,我们就知道是计算出来的 pos 是有问题的。噢噢!我们的 pos
+ children.length
其实是有可能会出现负数的,所以这里才会有问题。
所以我们要做的就是确保这个 pos 不会变成负数值,这里我们就可以运用数论里面的一个方法:
pos = (pos % children.length + children.length) % children.length;
让我们的 pos
先用 children.length
取余数,这样不管 pos
这个负数有多大,都会是一个小于 children.length
的一个值。
实现 panend 事件
这样我们就不会再报错了,接下来我们就可以继续取追加 “鼠标停下” 时的逻辑。鼠标停顿之前我们是用 “mouseup” 这个事件的。其实就是对应我们的 gesture 库中的 “panned” 事件。
除了这个事件名不同之外,其实其中的处理逻辑是一样的。只不过我们的 startX
就可以直接在我们的 event 中取就可以了。
this.root.addEventListener("panned", event => {
// 之前的 startX 我们可以直接在 event 中取得
// 因为我们在实现 gesture 的时候就都挂载到 event 中了
let x = event.clientX - event.startX;
position = position - Math.round(x / 500);
for (let offset of [0, -Math.sign(Math.round(x / 500) - x + 250 * Math.sign(x))]) {
let pos = position + offset;
// 计算图片所在 index
pos = ((pos % children.length) + children.length) % children.length;
children[pos].style.transition = '';
children[pos].style.transform = `translateX(${-pos * 500 + offset * 500}px)`;
}
})
到这里我们完全使用 Gesture 库来替代了我们原来的手势事件逻辑了。显然我们这里的代码相比之前是要清晰的多。更加遵循架构设计中的 “单一应用” 原则。我们 Carousel 中只有关于轮播图相关的逻辑,而所有手势事件的逻辑都是内聚到单独的 Gesture 库中。
Timeline 库应用
接下来我们来把 Timeline 的时间线也应用到我们的 Carousel 组件当中,并且使用我们的 Animation 来替换原来的 CSS 动画。
在这之前,我们先要在 carousel.js 中先引入我们的 Timeline, Animation 库和 Ease 动画。
import { Timeline, Animation } from './animation.js';
import { Ease } from './ease.js';
重构自动轮播动画
首先我们来看看怎么用 Timeline 和 Animation 来重构我们的自动轮播代码。
这里我们需要注意的是,在 main.html 中的 CSS 部分,我们之前给 .carousel > div
这写元素赋予了 transition
属性。这个属性会与我们 Animation 的动画产生冲突,所以我们要先把这个属性给注释掉。
.carousel > div {
width: 500px;
height: 281px;
background-size: contain;
display: inline-block;
/* transition: ease 0.5s; */
}
接下来我们看看原来的自动轮播代码,之前是用 currentIndex
(当前图片的 index)来寻找目标图片的。其实可以用我们手势事件部分用到的 position 替代这个值的。所以我们可以把这部分替换过来。
setInterval 前面计算图片节点部分的代码是没有任何问题的,所以我们可以保留,不需要做任何修改:
let children = this.root.children;
// 下一张图片的 index
let nextIndex = (position + 1) % children.length;
// 当前图片的节点
let current = children[position];
// 下一张图片的节点
let next = children[nextIndex];
接下来的那一部分代码,我们之前是使用一个 16 毫秒的 setTimeout
来执行我们的动画逻辑的。这部分就可以使用我们的 Timeline 来代替。
首先我们在 enableGesture
之前实例化,并且启动一个 Timeline。
/* ...代码 */
let timeline = new Timeline;
timeline.start();
enableGesture(this.root);
/* ...代码 */
我们注意到之前在移动图片的时候都是用百分比作为单位的。但是为了更加精确的操纵我们的动画,我们需要把单位都换成像素(px)。
在 let next
之后,加入我们的轮播动画。首先是移动当前窗口内的图片:
- 元素: current.style
- 动画属性: ‘transform’
- 开始属性值:
-position * 500
- 结束属性值:
-500 * (position + 1)
- 动画时长: 500
- 延迟值: 0
- 动画函数:
ease
- 动画模版: v => `translate(${v}px)`
// 先移动当前图片离开当前位置
timeline.add(new Animation(
current.style,
'transform',
-position * 500,
-500 * (position + 1),
0,
ease,
v => `translateX(${v}px)`
))
同时我们要移动下一张图的位置:
- 元素: next.style
- 动画属性: ‘transform’
- 开始属性值:
500 - nextIndex * 500
- 结束属性值:
-500 * nextIndex
- 动画时长: 500
- 延迟值: 0
- 动画函数:
ease
- 动画模版: v => `translate(${v}px)`
// 移动下一张图片到当前显示的位置
timeline.add(new Animation(
next.style,
'transform',
500 - nextIndex * 500,
-500 * nextIndex,
0,
ease,
v => `translateX(${v}px)`
))
最后,我们更新当前位置的 index:
// 最后更新当前位置的 index
position = nextIndex;
就这样,我们的自动轮播这部分重构完成了。
自动轮播和拖拽我们都重构完毕了,但是我们发现当我们在拖拽的时候,自动轮播会影响我们的拖拽。所以接下来我们就在解决轮播组件里最复杂的部分。
实现手势 + 轮播
首先,一旦我们鼠标或者手指点击我们轮播图的图片,我们就需要把图片自动轮播给停下。
这个就需要在我们 start
事件触发的时候去处理。
this.root.addEventListener('start', event => {
timeline.pause();
});
这里我们直接调用 timeline.pause()
来停止我们的时间线。
等一下,好像不对呀,我们的手势库里面并没有 dispatch 一个 start 事件哦。是的,所以这里我们就回去 gesture.js
中添加这个 start 事件的触发。
在 gesture 库的 Recognizer(识别器)中找到 start 方法。在这个方法的靠前的位置添加我们的 start 事件触发。
start(point, context) {
(context.startX = point.clientX), (context.startY = point.clientY);
this.dispatcher.dispatch('start');
/* 其余的代码... */
}
但是停止之后,我们再去拖拽的时候,就会发现我们图片位置就开始发生异常。
这个问题的是因为,我们的动画造成的图片位移的距离,并没有同步给我们手势计算。所以手势在计算位移的时候,认为当前图片还是第一张图片。
动画偏移距离
那么接下来我们要去算动画产生的位移距离,并且包含在我们手势计算中。
首先我们需要用一个变量,记录动画开始的时间:
render() {
// 在 render 函数的这个位置
// 加入一个 t 变量的声明
/* ... 省略了上面的代码 ... */
enableGesture(this.root);
let children = this.root.children;
let position = 0;
let t = 0;
/* ... 省略了下面的代码 ... */
}
然后在 setInterval 执行动画的地方记录动画开始时间:
render() {
/* ... 省略了上面的代码 ... */
setInterval() {
let children = this.root.children;
// 下一张图片的 index
let nextIndex = (position + 1) % children.length;
// 当前图片的节点
let current = children[position];
// 下一张图片的节点
let next = children[nextIndex];
// 记录动画开始时间
let t = Date.now();
/* ... */
}
/* ... 省略了下面的代码 ... */
}
在 start 时间触发的时候,也就是我们鼠标或者手指点击轮播图的时候,我们就计算当前动画播放的时间进度。这个进度我们可以根据当前动画已经播放了的时长来计算。
使用我们获得的时间进度,我们就可以通过 ease(progress) * 500 - 500
方法来获得当前动画进度所在的偏移距离。
这里的 ease(progress)
会返回一个 0 到 1 的值,根据二次贝塞尔曲线的概念,ease 会返回一个动画的进度(也就是动画执行了百分之多少)。使用这个进度乘于每个图片的 500 宽度。我们就可以得到一个当前图片的偏移距离。
这里因为我们已经播放到下一帧了,所以我们还需要再减到 500(一个图片的位置)。
let ax = 0; // 动画产生的 x 偏移距离
this.root.addEventListener('start', event => {
timeline.pause();
// 当前时间 - 动画开始时间 = 动画已经播放的时长
// 播放时长 / 1500 = 时间进度
let progress = (Date.now() - t) / 1500;
ax = ease(progress) * 500 - 500;
});
注意: 我们这里为什么用 1500?因为我们要调试,所以我们把所有动画的时间都改为 1500 毫秒,这样更加缓慢的动画会更利于我们调试。所以所有的图片轮播的效果也一样,改为使用 1500 毫秒。最后,等我们所有的功能都完善之后,都改回 500 毫秒即可。
最后在计算图片所在的 x 位置的时候,减掉自动轮播动画产生的 x 偏移距离 ax
,我们需要在 pan 和 panend 两个事件监听中修改这个逻辑:
let x = event.clientX - event.startX - ax;
这样我们在拖拽在时候,就不会再出现图片错位的现象的。但是如果我们拖拽的时候如果停下来不动的话,我们会发现图片还是在轮播。
这个问题是因为我们的自动播放动画的 setInterval 并没有停止,所以自动播放还是在跑的。所以我们还需要把这个 setInterval 给管理起来。
独立管理动画 Interval
要管理这个 setInterval 也很简单,只要把它赋予给一个变量,然后在 start 实现触发的时候 clear 掉这个 interval 即可。
首先我们把 setInterval 中的处理逻辑抽取出来单独储存:
// 动画处理逻辑
let nextAnimation = () => {
let children = this.root.children;
// 下一张图片的 index
let nextIndex = (position + 1) % children.length;
// 当前图片的节点
let current = children[position];
// 下一张图片的节点
let next = children[nextIndex];
t = Date.now();
// 先移动当前图片离开当前位置
timeline.add(
new Animation(
current.style,
'transform',
-position * 500,
-500 * (position + 1),
1500, // 动画改用了 1500 毫秒
0,
ease,
v => `translateX(${v}px)`
)
);
// 移动下一张图片到当前显示的位置
timeline.add(
new Animation(
next.style,
'transform',
500 - nextIndex * 500,
-500 * nextIndex,
1500, // 动画改用了 1500 毫秒
0,
ease,
v => `translateX(${v}px)`
)
);
// 最后更新当前位置的 index
position = nextIndex;
};
在 let ax
的声明下方加入 handler
变量声明,这个是用来储存我们的动画 interval 的:
let handler = null; // 动画 interval
把之前的 setInterval 赋予给 handler:
// 当前图片的 index
handler = setInterval(nextAnimation, 3000);
最后我们就可以在 start 触发时间里面 clear 掉这个 interval 了:
this.root.addEventListener('start', event => {
timeline.pause();
clearInterval(handler);
// 获取动画的进度
if (Date.now() - t < 1500) {
let progress = (Date.now() - t) / 1500;
ax = ease(progress) * 500 - 500; // 获取图片偏移的距离
} else {
// 如果没有完成一个 1500 毫秒的动画
// x 的偏移就可以忽略了
ax = 0;
}
});
这样,我们把图片捡起来之后就不会再出现任何问题了。但是,对 “但是”,还有问题。当我们放开之后,图片又错位了。
这个是因为我们还没有处理鼠标松开事件的逻辑。接下来我们一起补充这一块逻辑漏洞。
这一块逻辑我们需要在 panend 中去实现,其实之前 panend 中的逻辑是不健全的(如果还记得的同学,我们之前是偷懒了的。我们直接把自动播放动画注释掉,然后分开来实现我们的手势拖拽轮播逻辑的。)
所以这里我们需要基于 pan 事件中的逻辑,重新来编写我们 panend 的逻辑。
重写 panend 事件
因为我们在鼠标或者手指点击图片的时候,就停止的 timeline 时间线。自然在我们鼠标或者手指松开的时候就要重启 timeline,这样才能让动画继续播放。
所以第一个逻辑就是重启 Timeline:
this.root.addEventListener('panend', event => {
timeline.reset();
timeline.start();
});
第二步,就是要处理 panend 触发时,图片的动画。其实 panend 的动画的起点时和 pan 的动画是一致的,但是动画结束的点就不一致了。
Panend 结束的时候我们是需要获得一个 500 的倍数的。因为最终我们必须是看到一张完整的图片,而不能有其他的图片在两边的边缘。
所以,我们需要根据当前 x 的偏移值,计算我们当前的图片应该是往左边,还是往右边移动,从而完成这个图片的轮播效果。如果这个图片没有往左或者右边移动超过 50% 自身的长度,那么我们就应该让这个图片回到中间的位置。
而这个移动方向我们就叫它作 direction
,它的值只会是 -1
(左移)、0
(回中)、 1
(右移) 这三个值。
首先我们复用 pan 中的 x 和 current 值的计算:
let x = event.clientX - event.startX - ax;
let current = position - (x - (x % 500)) / 500;
然后我们加入 direction
的计算:
let direction = Math.round((x % 500) / 500);
这个 direction 的计算,无非就是把 x 除以 500 的余数,这个数值必然是小于 500 的。然后再除以 500 获得这个数值占比 500 的百分之多少。这个数字必然是 -1 到 1 之间。最后我们再用四舍五入获得一个 -1、0、1 三种结果。
有了 direction 值,我们就可以给当前这个图片加上一个动画了。
- 元素: children[pos].style
- 动画属性: ‘transform’
- 开始属性值:
-pos * 500 + offset * 500 + (x % 500)
- 结束属性值:
-pos * 500 + offset * 500 + direction * 500
- 动画时长: 1500
- 延迟值: 0
- 动画函数:
ease
- 动画模版: v => `translate(${v}px)`
加上了这个 Animation 之后,其实我们之前的问题是已经解决了。但是我们最后没有更新 position
的值。这样拖拽的时候回出现有图片跳动的现象。
最后我们来一起看一下怎么去更新 position
的值。
我们上面动画中,无非就是三种情况:
- 当前 position(位置)往左移动了一个图片的位置(direction = -1)
- 当前 position 往有移动了一个图片的位置(direction = 1)
- 当前 position 没有移动(direction = 0)
那么如果要计算下一个 position,无非就是 current 的位置 - direction 即可。
这里还有注意的一个点,如果我们拖动超过 3 个图片的位置时,position 就有可能会变成负数,所以我们在此使用之前用我们之前用过的手法,把 position 强制转为正数。
最后我们再补充一个逻辑,在 timeline start 的时候,我们再次启动一次我们动画的 setInterval,这样当我们停止拖拽的时候,自动轮播就会再次启动。
最后我们的 panend 的代码如下:
this.root.addEventListener('panend', event => {
timeline.reset();
timeline.start();
handler = setInterval(nextAnimation, 3000);
let x = event.clientX - event.startX - ax;
let current = position - (x - (x % 500)) / 500;
let direction = Math.round((x % 500) / 500);
以上是关于用 JavaScript 实现手势库 — 手势动画应用前端组件化的主要内容,如果未能解决你的问题,请参考以下文章
用 JavaScript 实现手势库 — 手势动画应用前端组件化
用 JavaScript 实现手势库 — 手势动画应用前端组件化
用 JavaScript 实现手势库 — 手势动画应用前端组件化
用 JavaScript 实现手势库 — 封装手势库前端组件化