用 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 * 500px)`;
    
)

到这里我们完全使用 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($vpx)`
// 先移动当前图片离开当前位置
timeline.add(new Animation(
    current.style,
    'transform',
    -position * 500, 
    -500 * (position + 1),
    0,
    ease,
    v => `translateX($vpx)`
))

同时我们要移动下一张图的位置:

  • 元素: next.style
  • 动画属性: ‘transform’
  • 开始属性值: 500 - nextIndex * 500
  • 结束属性值: -500 * nextIndex
  • 动画时长: 500
  • 延迟值: 0
  • 动画函数: ease
  • 动画模版: v => `translate($vpx)`
// 移动下一张图片到当前显示的位置
timeline.add(new Animation(
    next.style,
    'transform',
    500 - nextIndex * 500, 
    -500 * nextIndex,
    0,
    ease,
    v => `translateX($vpx)`
))

最后,我们更新当前位置的 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($vpx)`
    )
  );

  // 移动下一张图片到当前显示的位置
  timeline.add(
    new Animation(
      next.style,
      'transform',
      500 - nextIndex * 500,
      -500 * nextIndex,
      1500, // 动画改用了 1500 毫秒
      0,
      ease,
      v => `translateX($vpx)`
    )
  );

  // 最后更新当前位置的 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($vpx)`

加上了这个 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);

  for (let offset of 

以上是关于用 JavaScript 实现手势库 — 手势动画应用前端组件化的主要内容,如果未能解决你的问题,请参考以下文章

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

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

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

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

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

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