Событие swipe (CustomEvent)

Swipe является привычным для пользователей жестом и универсальным для разработчиков решением по обеспечению различных взаимодействий с интерфейсами. Для этого создано немало библиотек наподобие Hammer.JS или ZingTouch, позволяющих использовать свайпы не только на устройствах с сенсорным экраном, но и на обычных компьютерах при помощи мыши. Однако свайп, совмещающий разные методы ввода, может быть без труда реализован и без сторонних скриптов: речь пойдет о добавлении к элементам и дальнейшем прослушивании кастомного события swipe на чистом JavaScript.

Что представляет собой swipe

Swipe — разновидность жеста, при котором в течение определенного времени осуществляется непрерывное касание экрана указателем по условной кривой. Координаты начальной и конечной точек линии определяют длину и направление свайпа, что в сочетании со скоростью позволяет выделить его различные типы: прокрутка, перелистывание элементов, разблокирование экрана, открытие и сворачивание панелей и т. п. Как правило, в качестве указателя применяется палец или стилус, поэтому свайп традиционно считается сенсорным методом ввода, но технически реализуем и с помощью мыши.

Применительно к front end в спецификации JavaScript не представлено такого типа прослушиваемого события как swipe, поэтому оно принадлежит к custom events — искусственным (кастомным) событиям, которые генерируются конструкторами Event() и CustomEvent(). Для создания свайпов предпочтительнее выбрать последний, т.к. второй аргумент этого конструктора содержит дополнительное свойство detail, предназначенное для записи любой информации, которую разработчик посчитает нужным передать в дальнейшую обработку. Для полноценной обработки свайпов к таким данным должны относиться направление (up, right, down, left), дистанция (пройденное указателем расстояние от начала и до конца ввода) и время, потраченное на жест.

Создание и обработка события swipe

Чтобы создать кастомное событие swipe, поддерживающее мышь, и прослушивать его на элементе, необходимо написать функцию, работающую по следующему алгоритму:

  1. Определение событий, отслеживающих путь указателя. Для сенсорного ввода предназначены события из интерфейса TouchEvent, для мыши — MouseEvent. Также существует объединяющий их PointerEvent;
  2. Добавление предварительных настроек. Настраиваемые параметры позволяют интегрировать свайпы под конкретные задачи и фильтровать иные действия указателя, например, случайные касания;
  3. Отслеживание указателя при помощи выбранных событий. Зная время и координаты указателя в момент начала и завершения жеста, можно рассчитать дистанцию и направление движения, которые затем сопоставить с заданными параметрами;
  4. Проверка сделанного жеста. Если расстояние, пройденное указателем, и время, потраченное на действие, окажутся в рамках заданных значений, действие должно считаться свайпом и отправляться в общую систему событий через dispatchEvent(). В свою очередь свойство detail в конструкторе CustomEvent() используется для передачи в функцию-обработчик направления, времени и дистанции свайпа.

Нюансы использования стандартных событий

Для определения жеста swipe используется набор стандартных событий, посредством которых отслеживаются все стадии действия указателя: начало касания (начальная точка), движение, завершение касания (конечная точка). В зависимости от браузера и возможности сенсорного ввода, события указателя могут относится к интерфейсам MouseEvent, TouchEvent или универсальному PointerEvent. Чтобы не добавлять к элементу множество событий и оперировать только поддерживаемыми браузером пользователя, предлагается создать отдельную функцию, которая агрегирует все события, но возвращает лишь необходимые (включая префиксные события MSPointer для Internet Explorer 10).

До вычисления координат указателя следует заранее позаботиться об «объединении» случаев click и touch: поскольку список TouchList доступен только на устройствах, поддерживающих сенсорный ввод, выражение наподобие Event.changedTouches[0].pageX вернет undefined в desktop-браузерах:

/**
* Опредление доступных в браузере событий: pointer, touch и mouse.
* @returns {object} - возвращает объект с названиями событий.
*/
var getSupportedEvents = function() {
  var events, support = {
    pointer: !!("PointerEvent" in window || (window.MSPointerEvent && window.navigator.msPointerEnabled)),
    touch: !!(typeof window.orientation !== "undefined" || /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) || "ontouchstart" in window || navigator.msMaxTouchPoints || 'maxTouchPoints' in window.navigator > 1 || 'msMaxTouchPoints' in window.navigator > 1)
  };
  switch (true) {
    case support.pointer:
      events = {
        type:   "pointer",
        start:  "PointerDown",
        move:   "PointerMove",
        end:    "PointerUp",
        cancel: "PointerCancel",
        leave:  "PointerLeave"
      };
      // добавление префиксов для IE10
      var ie10 = (window.navigator.msPointerEnabled && Function('/*@cc_on return document.documentMode===10@*/')());
      for (var value in events) {
        if (value === "type") continue;
        events[value] = (ie10) ? "MS" + events[value] : events[value].toLowerCase();
      }
      break;
    case support.touch:
      events = {
        type:   "touch",
        start:  "touchstart",
        move:   "touchmove",
        end:    "touchend",
        cancel: "touchcancel"
      };
      break;
    default:
      events = {
        type:  "mouse",
        start: "mousedown",
        move:  "mousemove",
        end:   "mouseup",
        leave: "mouseleave"
      };
      break;
  }
  return events;
};

/**
* Объединение событий mouse/pointer и touch.
* @param e {object} - принимает в качестве аргумента событие.
* @returns {TouchList|e} возвращает либо TouchList, либо оставляет событие без изменения.
*/
var eventsUnify = function(e) {
  return e.changedTouches ? e.changedTouches[0] : e;
};

Настройки дистанции и тайм-аута свайпа

Для удобной интеграции с интерфейсом сайта свайпы должны обладать предварительно настраиваемыми параметрами — минимальной дистанцией, максимальной дистанцией и временем, за которое жест должен успеть быть выполнен. Ограничения по длине и времени служат фильтром, исключающим слишком короткие и быстрые (случайные) или длинные и долгие касания: они не будут отправляться через dispatchEvent(), ровно как и не станут считаться свайпами касания двумя пальцами на устройствах с touchscreen.

Предварительные настройки необязательны, но могут передаваться в функцию вторым аргументом как объект. Первым аргументом является элемент, на котором необходимо отследить событие swipe:

var swipe = function(el, settings) {
  // настройки по умолчанию
  var settings = Object.assign({}, {
    minDist: 60,  // минимальная дистанция, которую должен пройти указатель, чтобы жест считался как свайп (px)
    maxDist: 120, // максимальная дистанция, не превышая которую может пройти указатель, чтобы жест считался как свайп (px)
    maxTime: 700, // максимальное время, за которое должен быть совершен свайп (ms)
    minTime: 50   // минимальное время, за которое должен быть совершен свайп (ms)
  }, settings);
}

Обработка движений указателя на элементе

Для обработки движения указателя необходимо создать три отдельные функции для каждой стадии — start, move и end. Получаемые ими события унифицируются через eventsUnify(), а названия этих событий, добавляемых для элемента через eventListener(), возвращаются из getSupportedEvents().

На последней стадии отслеживания, во время завершения касания, простым условием определяется, соответствует ли жест установленным параметрам свайпа. В случае совпадения инициализируется новое событие swipe, помимо прочего содержащее объект detail с тремя свойствами: полным событием, направлением, временем (ms) и дистанцией свайпа (px) в абсолютном значении:

/**
 * Функция определения события swipe на элементе.
 * @param {Object} el - элемент DOM.
 * @param {Object} settings - объект с предварительными настройками.
 */
var swipe = function(el, settings) {

  // настройки по умолчанию
  var settings = Object.assign({}, {
    minDist: 60,
    maxDist: 120,
    maxTime: 700,
    minTime: 50
  }, settings);

  // коррекция времени при ошибочных значениях
  if (settings.maxTime < settings.minTime) settings.maxTime = settings.minTime + 500;
  if (settings.maxTime < 100 || settings.minTime < 50) {
    settings.maxTime = 700;
    settings.minTime = 50;
  }

  var el = this.el,       // отслеживаемый элемент
    dir,                  // направление свайпа (horizontal, vertical)
    swipeType,            // тип свайпа (up, down, left, right)
    dist,                 // дистанция, пройденная указателем
    isMouse = false,      // поддержка мыши (не используется для тач-событий)
    isMouseDown = false,  // указание на активное нажатие мыши (не используется для тач-событий)
    startX = 0,           // начало координат по оси X (pageX)
    distX = 0,            // дистанция, пройденная указателем по оси X
    startY = 0,           // начало координат по оси Y (pageY)
    distY = 0,            // дистанция, пройденная указателем по оси Y
    startTime = 0,        // время начала касания
    support = {           // поддерживаемые браузером типы событий
      pointer: !!("PointerEvent" in window || ("msPointerEnabled" in window.navigator)),
      touch: !!(typeof window.orientation !== "undefined" || /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) || "ontouchstart" in window || navigator.msMaxTouchPoints || "maxTouchPoints" in window.navigator > 1 || "msMaxTouchPoints" in window.navigator > 1)
    };

  /**
   * Опредление доступных в браузере событий: pointer, touch и mouse.
   * @returns {Object} - возвращает объект с доступными событиями.
   */
  var getSupportedEvents = function() {
    switch (true) {
      case support.pointer:
        events = {
          type:   "pointer",
          start:  "PointerDown",
          move:   "PointerMove",
          end:    "PointerUp",
          cancel: "PointerCancel",
          leave:  "PointerLeave"
        };
        // добавление префиксов для IE10
        var ie10 = (window.navigator.msPointerEnabled && Function('/*@cc_on return document.documentMode===10@*/')());
        for (var value in events) {
          if (value === "type") continue;
          events[value] = (ie10) ? "MS" + events[value] : events[value].toLowerCase();
        }
        break;
      case support.touch:
        events = {
          type:   "touch",
          start:  "touchstart",
          move:   "touchmove",
          end:    "touchend",
          cancel: "touchcancel"
        };
        break;
      default:
        events = {
          type:  "mouse",
          start: "mousedown",
          move:  "mousemove",
          end:   "mouseup",
          leave: "mouseleave"
        };
        break;
    }
    return events;
  };


  /**
   * Объединение событий mouse/pointer и touch.
   * @param e {Event} - принимает в качестве аргумента событие.
   * @returns {TouchList|Event} - возвращает либо TouchList, либо оставляет событие без изменения.
   */
  var eventsUnify = function(e) {
    return e.changedTouches ? e.changedTouches[0] : e;
  };


  /**
   * Обрабочик начала касания указателем.
   * @param e {Event} - получает событие.
   */
  var checkStart = function(e) {
    var event = eventsUnify(e);
    if (support.touch && typeof e.touches !== "undefined" && e.touches.length !== 1) return; // игнорирование касания несколькими пальцами
    dir = "none";
    swipeType = "none";
    dist = 0;
    startX = event.pageX;
    startY = event.pageY;
    startTime = new Date().getTime();
    if (isMouse) isMouseDown = true; // поддержка мыши
    e.preventDefault();
  };

  /**
   * Обработчик движения указателя.
   * @param e {Event} - получает событие.
   */
  var checkMove = function(e) {
    if (isMouse && !isMouseDown) return; // выход из функции, если мышь перестала быть активна во время движения
    var event = eventsUnify(e);
    distX = event.pageX - startX;
    distY = event.pageY - startY;
    if (Math.abs(distX) > Math.abs(distY)) dir = (distX < 0) ? "left" : "right";
    else dir = (distY < 0) ? "up" : "down";
    e.preventDefault();
  };

  /**
   * Обработчик окончания касания указателем.
   * @param e {Event} - получает событие.
   */
  var checkEnd = function(e) {
    if (isMouse && !isMouseDown) { // выход из функции и сброс проверки нажатия мыши
      mouseDown = false;
      return;
    }
    var endTime = new Date().getTime();
    var time = endTime - startTime;
    if (time >= settings.minTime && time <= settings.maxTime) { // проверка времени жеста
      if (Math.abs(distX) >= settings.minDist && Math.abs(distY) <= settings.maxDist) {
        swipeType = dir; // опредление типа свайпа как "left" или "right"
      } else if (Math.abs(distY) >= settings.minDist && Math.abs(distX) <= settings.maxDist) {
        swipeType = dir; // опредление типа свайпа как "top" или "down"
      }
    }
    dist = (dir === "left" || dir === "right") ? Math.abs(distX) : Math.abs(distY); // опредление пройденной указателем дистанции

    // генерация кастомного события swipe
    if (swipeType !== "none" && dist >= settings.minDist) {
      var swipeEvent = new CustomEvent("swipe", {
          bubbles: true,
          cancelable: true,
          detail: {
            full: e, // полное событие Event
            dir:  swipeType, // направление свайпа
            dist: dist, // дистанция свайпа
            time: time // время, потраченное на свайп
          }
        });
      el.dispatchEvent(swipeEvent);
    }
    e.preventDefault();
  };

  // добавление поддерживаемых событий
  var events = getSupportedEvents();

  // проверка наличия мыши
  if ((support.pointer && !support.touch) || events.type === "mouse") isMouse = true;

  // добавление обработчиков на элемент
  el.addEventListener(events.start, checkStart);
  el.addEventListener(events.move, checkMove);
  el.addEventListener(events.end, checkEnd);

};

Пример реализации свайпов (vanilla JS)

Для отслеживания свайпов достаточно вызывать функцию swipe() и установить соответствующий прослушиватель addEventListener("swipe", function) на элемент. Стоит отметить, что функция обладает широкой кроссбраузерностью: поддерживаются Opera 12 (Presto) и IE 9+ (не без помощи пары полифиллов).

// элемент
var myBlock = document.getElementById("menu");

// вызов функции swipe с предварительными настройками
swipe(myBlock, { maxTime: 1000, minTime: 100, maxDist: 150,  minDist: 60 });

// обработка свайпов
myBlock.addEventListener("swipe", function() {
  console.log(e.detail);
});

Направление, дистанция и потраченное на свайп время передаются в функцию-обработчик как свойства e.detail.dir, e.detail.dist и e.detail.time соответственно. При необходимости доступно обращение к полному событию — e.detail.full. Ниже представлен пример, демонстрирующий возможности совместного использования этих свойств:

В заключение

Благодаря выборочному использованию событий (в зависимости от поддержки браузером) описанное решение по определению свайпов корректно работает как с сенсором на мобильных устройствах, так и с обычной мышью. Миниатюрный скрипт на чистом JS позволяет избежать подключения лишних библиотек, а настраиваемые параметры помогут приспособить свайпы под различные интерфейсные задачи.