用 JavaScript 实现手势库 — 手势动画应用前端组件化

Posted 三钻

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了用 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 实现手势库 — 封装手势库前端组件化

用 JavaScript 实现手势库 — 封装手势库前端组件化

用 JavaScript 实现手势库 — 封装手势库前端组件化