用 JavaScript 实现手势库 — 事件派发与 Flick 事件【前端组件化】

1,707 阅读4分钟

前端《组件化系列》目录

我们上一期已经实现了所有的 gesture(手势),接下来我们需要实现的就是事件派发的功能。

事件派发

在 DOM 里面事件的派发是使用 new Event , 然后在上面加一些属性,最后把这个事件给派发出去的。

所以我们这里也是一样,建立一个 dsipatch 的函数,并且加入 typeproperty 这些参数。这里的 property 含有 context 对象和 point 坐标两个属性。

在我们的 dispatch 函数中,首先我们需要做的就是创建一个 event 对象。在新的浏览器 API 中,我们可以直接使用 new Event 来创建。当然我们也可以使用自定义事件来创建 new CustomEvent。那么我们这里,就用普通的 new Event 就好了。

function dispatch(type, properties) {
  let event = new Event(type);
}

然后我们循环一下 properties 这个对象,把里面的属性都抄写一下。然后我们新创建的 event 是需要挂在一个元素上面,把它挂在到我们之前定义的 element 上即可。

function dispatch(type, properties) {
  let event = new Event(type);
  for (let name in properties) {
    event[name] = properties[name];
  }
  element.dispatchEvent(event);
}

这里其实还有一个问题,就是我们之前写的监听都是挂载在 element 之上的。最后我们要把这些都换成挂载在 document 上。

element.addEventListener('mousedown', event => {
  let context = Object.create(null);
  contexts.set(`mouse${1 << event.button}`, context);

  start(event, context);

  let mousemove = event => {
    let button = 1;

    while (button <= event.buttons) {
      if (button & event.buttons) {
        let key;
        // Order of buttons & button is not the same
        if (button === 2) {
          key = 4;
        } else if (button === 4) {
          key = 2;
        } else {
          key = button;
        }

        let context = contexts.get('mouse' + key);
        move(event, context);
      }
      button = button << 1;
    }
  };

  let mouseup = event => {
    let context = contexts.get(`mouse${1 << event.button}`);
    end(event, context);
    contexts.delete(`mouse${1 << event.button}`);

    if (event.buttons === 0) {
      document.removeEventListener('mousemove', mousemove);
      document.removeEventListener('mouseup', mouseup);
      isListeningMouse = false;
    }
  };

  if (!isListeningMouse) {
    document.addEventListener('mousemove', mousemove);
    document.addEventListener('mouseup', mouseup);
    isListeningMouse = true;
  }
});

然后我们来把 end 函数中的 tap 事件 dipatch(派发)出来试试:

let end = (point, context) => {
  if (context.isTap) {
    //console.log('tap');
    // 把原先的 console.log 换成 dispatch 调用
    // 这个事件不需要任何特殊属性,直接传`空对象`即可
    dispatch('tap', {})
    clearTimeout(context.handler);
  }

  if (context.isPan) {
    console.log('pan-end');
  }

  if (context.isPress) {
    console.log('press-end');
  }
};

那么最后,我们可以尝试在 HTML 中加入一个脚本,在里面监听一下我们新创建的 tap 事件。

<script src="gesture.js"></script>
<body oncontextmenu="event.preventDefault()"></body>
<script>
  document.documentElement.addEventListener('tap', () => {
    console.log('Tapped!');
  });
</script>

这个时候,如果我们去浏览器上点击一下,就会触发我们的 tap 事件,并且输出我们的 'Tapped' 消息了!

这样我们的派发事件就大功告成了。

实现一个 flick 事件

这里我们一起来完成最后一个最特别的 flick 事件。Flick 事件在我们所有的事件体系里是比较特殊的,因为它是一个需要判断数独的一个事件。

根据我们前面讲到的,在 pan start 之后,如果我们在手指离开屏幕之前,我们执行了一个快速滑动手指的动作,到达一定的速度以上就会触发我们的 flick 事件,而不是原本的 pan end 的事件。

那么需要如何判断这个速度的?其实可以在我们的 move 函数中,获得当前这一次移动时的速度。但是这个并不能帮助我们去处理,因为如果只按照两个点之间移动时的速度,根据浏览器实现的不同,它会有一个较大的误差。

所以更加准确的方式就是,取数个点,然后用它们之间的平均值作为判定的值。那么要实现这个功能,我们就需要存储一段时间之内的这些点,然后使用这些点来计算出速度的平均值。

有了实现的思路了,我们就来整理下,在代码中怎么去编写这一块的逻辑。

首先我们需要在触发 start 的时候,就把第一个记录点加入到我们的全局 context 之中。而这里需要记录几个值:

  • t:代表当前点触发/加入时的时间,这里我们使用 Date.now()
  • x:代表当前点 x 轴的坐标
  • y:代表当前点 y 轴的坐标

这些值到了后面都会用来计算移动速度的。

let start = (point, context) => {
  (context.startX = point.clientX), (context.startY = point.clientY);

  context.points = [
    {
      t: Date.now(),
      x: point.clientX,
      y: point.clientY,
    },
  ];

  context.isPan = false;
  context.isTap = true;
  context.isPress = false;

  context.handler = setTimeout(() => {
    context.isPan = false;
    context.isTap = false;
    context.isPress = true;
    console.log('press-start');
    context.handler = null;
  }, 500);
};

然后每一次触发 move 的时候,都给当前的 content 放入一个新的点。但是在加入新的点之前,需要过滤一次已经存储的点。我们只需要最近 500 毫秒内的点来计算速度即可,其余的点就可以过滤掉了。

在执行 flick 动作的时候,我们是不会滑动一个很长的距离和时间的,加上我们是需要捕捉一个快速的滑动动作,这个动作肯定是在 500 毫秒以内的动作,要不也不叫 “快” 了。所以这里就只需要 500 毫秒内的点即可。

let move = (point, context) => {
  let dx = point.clientX - context.startX,
    dy = point.clientY - context.startY;

  if (!context.isPan && dx ** 2 + dy ** 2 > 100) {
    context.isPan = true;
    context.isTap = false;
    context.isPress = false;
    console.log('pan-start');
    clearTimeout(context.handler);
  }

  if (context.isPan) {
    console.log(dx, dy);
    console.log('pan');
  }

  context.points = context.points.filter(point => Date.now() - point.t < 500);

  context.points.push({
    t: Date.now(),
    x: point.clientX,
    y: point.clientY,
  });
};

在 end 事件触发的时候,就可以来计算这次滑动的速度了。因为这里是计算用户滑动时的速度,如果用户是其他类型的手势动作,是不需要去计算速度的。所以这段计算逻辑就可以写在 isPan 成立的判断里面即可。

首先给这个手势动作一个状态变量 isFlick,并且给予它一个默认值为 false

在计算速度之前,一样需要过滤一次我们 context 中储存的全部的点,把 500 毫秒之外的点过滤掉。

在数学或者物理中,有一个计算速度的公式: 速度 = 距离 / 用时。那么这里要去计算速度的话,首先需要计算的就是距离。而这里要计算的是直径距离,所以需要 x 轴和 y 轴的距离的二次幂相加,然后开根号获得的值就是我们要的直径距离。

那么 x 轴距离为例,就是当前点的 x 轴坐标,减去记录中第一个点的 x 轴左边。y 轴的距离就同理可得了。那么有了距离,我们就可以直接从当前点和第一个点的时间差获得 用时。最后就可以运算出速度。

let end = (point, context) => {
  context.isFlick = false;

  if (context.isTap) {
    //console.log('tap');
    // 把原先的 console.log 换成 dispatch 调用
    // 这个事件不需要任何特殊属性,直接传`空对象`即可
    dispatch('tap', {});
    clearTimeout(context.handler);
  }

  if (context.isPan) {
    context.points = context.points.filter(point => Date.now() - point.t < 500);

    let d = Math.sqrt((point.x - context.points[0].x) ** 2 + (point.y - context.points[0].y) ** 2);
    let v = d / (Date.now() - context.points[0].t);
  }

  if (context.isPress) {
    console.log('press-end');
  }
};

好样的,这样我们就有两个点之间的 v 速度。那么现在呢,我们需要知道多快的速度才能认为是一个 flick 动作呢?这里就用上帝视角直接得出 1.5 像素每毫秒的速度就是最合适的(这个怎么算出来的?其实我们可以直接 console.log(v),把速度打印出啦,然后我们手动去测试,就会发现大概 v = 1.5 的时候差不多就是对的了)。

所以我们这里直接就可以判断, 如果 v > 1.5 的话,我们就认为用户的手势就是一个 flick,否则就是普通的 pan-end。

let end = (point, context) => {
  context.isFlick = false;

  if (context.isTap) {
    //console.log('tap');
    // 把原先的 console.log 换成 dispatch 调用
    // 这个事件不需要任何特殊属性,直接传`空对象`即可
    dispatch('tap', {});
    clearTimeout(context.handler);
  }

  if (context.isPan) {
    context.points = context.points.filter(point => Date.now() - point.t < 500);

    let d = Math.sqrt((point.x - context.points[0].x) ** 2 + (point.y - context.points[0].y) ** 2);
    let v = d / (Date.now() - context.points[0].t);

    if (v > 1.5) {
      context.isFlick = true;
      dispatch('flick', {});
    } else {
      context.isFlick = false;
      dispatch('panend', {});
    }
  }

  if (context.isPress) {
    console.log('press-end');
  }
};

这样 flick 事件的处理就完成了,其实这段代码中还有一些 console.log() 是没有被改为使用 dispatch 给派发出去的。但是接下来就要开始看看怎么重新封装这个手势库了,所以这里我们就不一一更改过来先了。

如果想把这里的代码写完整的同学,可以自行把所有的 console.log(事件名) 部分的代码都改正过来哦~

最后附上到此完整的代码。

let element = document.documentElement;

let contexts = new Map();

let isListeningMouse = false;

element.addEventListener('mousedown', event => {
  let context = Object.create(null);
  contexts.set(`mouse${1 << event.button}`, context);

  start(event, context);

  let mousemove = event => {
    let button = 1;

    while (button <= event.buttons) {
      if (button & event.buttons) {
        let key;
        // Order of buttons & button is not the same
        if (button === 2) {
          key = 4;
        } else if (button === 4) {
          key = 2;
        } else {
          key = button;
        }

        let context = contexts.get('mouse' + key);
        move(event, context);
      }
      button = button << 1;
    }
  };

  let mouseup = event => {
    let context = contexts.get(`mouse${1 << event.button}`);
    end(event, context);
    contexts.delete(`mouse${1 << event.button}`);

    if (event.buttons === 0) {
      document.removeEventListener('mousemove', mousemove);
      document.removeEventListener('mouseup', mouseup);
      isListeningMouse = false;
    }
  };

  if (!isListeningMouse) {
    document.addEventListener('mousemove', mousemove);
    document.addEventListener('mouseup', mouseup);
    isListeningMouse = true;
  }
});

element.addEventListener('touchstart', event => {
  for (let touch of event.changedTouches) {
    let context = Object.create(null);
    contexts.set(event.identifier, context);
    start(touch, context);
  }
});

element.addEventListener('touchmove', event => {
  for (let touch of event.changedTouches) {
    let context = contexts.get(touch.identifier);
    move(touch, context);
  }
});

element.addEventListener('touchend', event => {
  for (let touch of event.changedTouches) {
    let context = contexts.get(touch.identifier);
    end(touch, context);
    contexts.delete(touch.identifier);
  }
});

element.addEventListener('cancel', event => {
  for (let touch of event.changedTouches) {
    let context = contexts.get(touch.identifier);
    cancel(touch, context);
    contexts.delete(touch.identifier);
  }
});

let start = (point, context) => {
  (context.startX = point.clientX), (context.startY = point.clientY);

  context.points = [
    {
      t: Date.now(),
      x: point.clientX,
      y: point.clientY,
    },
  ];

  context.isPan = false;
  context.isTap = true;
  context.isPress = false;

  context.handler = setTimeout(() => {
    context.isPan = false;
    context.isTap = false;
    context.isPress = true;
    console.log('press-start');
    context.handler = null;
  }, 500);
};

let move = (point, context) => {
  let dx = point.clientX - context.startX,
    dy = point.clientY - context.startY;

  if (!context.isPan && dx ** 2 + dy ** 2 > 100) {
    context.isPan = true;
    context.isTap = false;
    context.isPress = false;
    console.log('pan-start');
    clearTimeout(context.handler);
  }

  if (context.isPan) {
    console.log(dx, dy);
    console.log('pan');
  }

  context.points = context.points.filter(point => Date.now() - point.t < 500);

  context.points.push({
    t: Date.now(),
    x: point.clientX,
    y: point.clientY,
  });
};

let end = (point, context) => {
  context.isFlick = false;

  if (context.isTap) {
    //console.log('tap');
    // 把原先的 console.log 换成 dispatch 调用
    // 这个事件不需要任何特殊属性,直接传`空对象`即可
    dispatch('tap', {});
    clearTimeout(context.handler);
  }

  if (context.isPan) {
    context.points = context.points.filter(point => Date.now() - point.t < 500);

    let d, v;
    if (!context.points.length) {
      v = 0;
    } else {
      d = Math.sqrt(
        (point.clientX - context.points[0].x) ** 2 + (point.clientY - context.points[0].y) ** 2
      );
      v = d / (Date.now() - context.points[0].t);
    }

    if (v > 1.5) {
      context.isFlick = true;
      dispatch('flick', {});
    } else {
      context.isFlick = false;
      dispatch('panend', {});
    }
  }

  if (context.isPress) {
    console.log('press-end');
  }
};

let cancel = (point, context) => {
  clearTimeout(context.handler);
  console.log('cancel');
};

function dispatch(type, properties) {
  let event = new Event(type);
  for (let name in properties) {
    event[name] = properties[name];
  }
  element.dispatchEvent(event);
}

下一期,我们就来做手势库的最后一步,封装!~

我是来自《技术银河》的三钻,一位正在重塑知识的技术人。下期再见。


⭐️ 三哥推荐

开源项目推荐

Hexo Theme Aurora

在最近在版本 1.5.0 更新了以下功能:

预览

:sparkles: 新增

  • 自适应 “推荐文章” 布局 (增加了一个新的 “置顶文章布局” !!)
    • 能够在“推荐文章”和“置顶文章”模式之间自由切换
    • 如果总文章少于 3 篇,将自动切换到“置顶文章”模式
    • 在文章卡上添加了“置顶”和“推荐”标签
    • :book: 文档
  • 增加了与 VuePress 一样的自定义容器 #77
    • Info 容器
    • Warning 容器
    • Danger 容器
    • Detail 容器
    • 预览
  • 支持了更多的 SEO meta 数据 #76
    • 添加了 description
    • 添加了 keywords
    • 添加了 author
    • :book: 文档

最近博主在全面投入开发一个可以 “迈向未来的” Hexo 主题,以极光为主题的博客主题。

如果你是一个开发者,做一个个人博客也是你简历上的一个亮光点。而如果你有一个超级炫酷的博客,那就更加是亮上加亮了,简直就闪闪发光。

如果喜欢这个主题,可以在 Github 上给我点个 🌟 让彼此都发光吧~

主题 Github 地址:github.com/auroral-ui/… 主题使用文档:aurora.tridiamond.tech/zh/


VSCode Aurora Future

对,博主还做了一个 Aurora 的 VSCode 主题。用了Hexo Theme Aurora 相对应的颜色色系。这个主题的重点特性的就只用了 3 个颜色,减少在写代码的时候被多色多彩的颜色所转移了你的注意力,让你更集中在写代码之中。

喜欢的大家可以支持一下哦! 直接在 VSCode 的插件搜索中输入 “Aurora Future” 即可找到这个主题哦!~

主题 Github 地址:github.com/auroral-ui/… 主题插件地址:marketplace.visualstudio.com/items?itemN…


Firefox Aurora Future

我不知道大家,但是最近我在用火狐浏览器来做开发了。个人觉得火狐还真的是不错的。推荐大家尝试一下。

当然我这里想给大家介绍的是我在火狐也做了一个 Aurora 主题。对的!用的是同一套的颜色体系。喜欢的小伙伴可以试一下哦!

主题地址:addons.mozilla.org/en-US/firef…


博主开始在B站直播学习,欢迎过来《直播间》一起学习。

我们在这里互相监督,互相鼓励,互相努力走上人生学习之路,让学习改变我们生活!

学习的路上,很枯燥,很寂寞,但是希望这样可以给我们彼此带来多一点陪伴,多一点鼓励。我们一起加油吧! (๑ •̀ㅂ•́)و


掘金关注专栏.png