Реализуем touch жесты на vanilla js. Часть 1 (rotate)

556e40b64232b0f85c2461d2bfa73a10.png

Столкнувшись с задачей реализовать простой графический редактор в мобильной версии сайта, я обнаружил, что функционал мультитач жестов еще не реализован на уровне браузера.

В mdn я нашел Api gesture events, но оно не поддерживается примерно нигде.

Untitled

https://developer.mozilla.org/en-US/docs/Web/API/GestureEvent

Начнем мы с вращения и, если тема будет интересна, я распишу, как реализовать scale и drag.

Для реализации этого функционала нам потребуется расчехлить тригонометрию.

Сначала попробуем выяснить, что представляет из себя наш жест с точки зрения js и с какими данными нам нужно работать.

Тестовый стенд

Адекватного способа тестировать мультитач в десктопном браузере я не нашел (если вы такой знаете, напишите в комментах), поэтому пользовался эмулятором iphone c подключенной консолью разработчика safari.

Точно также можно использовать эмулятор android вместе с chrome. Однако я бы советовал не забывать проверять ваше решение на настоящем девайсе.

Напишем немного кода:

HTML

CSS

#rect {
  background-color: red;
  width: 500px;
  height: 500px;
}

JS

const rect = document.getElementById("rect");
// Начало прикосновения
rect.addEventListener("touchstart", (e) => {
  // Чтобы убрать нежелательное смещение экрана при тачмуве
  e.preventDefault();
  console.log(e);
});
// Каждый акт движения пальцами
rect.addEventListener("touchmove", (e) => {
  e.preventDefault();
  console.log(e);
});

При попытке покрутить наш прямоугольник мы увидем множество событий TouchEvent

Untitled

Вывод консоли

Нам необходимо получить список нажатий из каждого TouchEvent, и у нас есть 3 варианта:

  • .touches — положение всех прикосновений пальцев

  • .targetTouches — прикосновения внутри нашего div

  • .changedTouches — прикосновения внутри элемента, которые изменили свое положение

Каждое из этих свойств содержит объект TouchList, состоящий из объектов Touch, которые нас и интересуют

Для нашей задачи лучше всего подходит targetTouches

Untitled

Список объектов-прикосновений Touch

Каждый объект Touch содержит набор координат вида: clientX, pageY, итп
Разница между client и page в том, что первый отсчитываться от текущего viewport (края экрана), а второй от начала страницы.

В нашем случае особой разницы нет, так как для нас будет важна дельта.

Достанем все полезные данные из targetTouches

rect.addEventListener("touchmove", (e) => {
  e.preventDefault();
  // e.targetTouches это не массив
  const touches = Array.prototype.map.call(e.targetTouches, (t) => {
    return {
      x: t.clientX,
      y: t.clientY,
    };
  });
  console.log(touches);
});

targetTouches это не настоящий массив, поэтому чтобы обработать его с помощью map, нам придется совершить маленькую хитрость с помощью call

Untitled

Массив объектов с координатами прикосновений

Итак, вводные данные у нас есть, теперь подключаем математику, чтобы найти решение нашей задачи.

М — математика

Дано: 2 точки, в которых пользователь касается объекта.

[x1,y1] [x2,y2]

Untitled

Нам нужно найти угол, на который пользователь повернул воображаемую линию между 2 мя точками, и спроецировать его на поворачиваемый объект.

На иллюстрации справа у нас образовался прямоугольный треугольник. А это значит, что мы можем пользоваться соответствующими формулами для поиска интересующего нас угла.

Untitled

Вспоминаем школьную тригонометрию.

Тангенсом острого угла прямоугольного треугольника называется отношение противолежащего этому углу катета к прилежащему катету.

tg B = AC/CB

Чтобы найти B нам нужна обратная тригонометрическая функция тангенса, а именно арктангенс. Однако эта функция имеет некоторые ограничения, поэтому в нашем случае нужно будет взять atan2.

Для тех кто хочет погрузится глубже, в википедии есть вполне подробное объяснение https://en.wikipedia.org/wiki/Atan2

Получем:

B = atan2(AC, AB)

Реализуем вращение

const angle = Math.atan2(
  touches[1].y - touches[0].y,
  touches[1].x - touches[0].x
);

Добавим проверку, что у нас есть 2 касания на экране и модифицируем стили нашего квадрата добавив трансформацию с поворотом:

rect.addEventListener("touchmove", (e) => {
  e.preventDefault();
  const touches = Array.prototype.map.call(e.targetTouches, (t) => {
    return {
      x: t.clientX,
      y: t.clientY,
    };
  });
  if (touches.length > 1) {
    // считаем угол между точками
    const angle = Math.atan2(
      touches[1].y - touches[0].y,
      touches[1].x - touches[0].x
    );
    console.log(angle);
    
    // поворачиваем наш квадрат
    rect.style.transform = `rotate(${angle}rad)`;
  }
});

Обратите внимание, что функция atan2 возвращает результат в радианах, а не в привычных градусах.

Почти все, но есть один нюанс. При каждой новой попытке повернуть наш квадрат его предыдущий угол будет сбрасываться и вращение будет некрасиво начинаться с 0 радиан, разрушая всю иммерсивность взаимодействия.

72182290e51107214f5f67215ac6cfa7.gif

Чтобы этого избежать мы должны:

  1. В начале каждого вращения запоминать угол между пальцами в стартовых точках и вычитать это значение из результирующего угла, чтобы избежать скачка в самом начале вращения

Untitled

Чтобы избежать скачка, нам нужно компенсировать стартовый угол между касаниями

  1. Сохранять промежуточное значение угла после того как пользователь закончил вращение. При последующем вращении докручивать квадрат, добавляя новый угол к предыдущему. Это позволит избежать скачка в начале каждого последующего вращения.

b0cefd3a09fc8ae7dcea01559544e75b.gif

Вынесем код в функции чтобы избежать дублирования

// Подготовить массив нажатий
function prepareTouches(e) {
  return Array.prototype.map.call(e.targetTouches, (t) => {
    return {
      x: t.clientX,
      y: t.clientY,
    };
  });
}
// Считаем угол между точками
function calculatePointersAngle(touches) {
  return Math.atan2(touches[1].y - touches[0].y, touches[1].x - touches[0].x);
}

Определим переменные, в которых будем хранить промежуточные данные

// Угол поворта квадрата
let angle = 0;
// Угол между пальцами
let pointersAngle = 0;

В момент прикосновения посчитаем изначальный угол между точками

rect.addEventListener("touchstart", (e) => {
  e.preventDefault();
  const touches = prepareTouches(e);
  if (touches.length > 1) {
    pointersAngle = calculatePointersAngle(touches);
  }
});

А теперь доработаем нашу функцию обработки touchmove

rect.addEventListener("touchmove", (e) => {
  e.preventDefault();
  const touches = prepareTouches(e);
  if (touches.length > 1) {
    // Убираем предыдущий угол между нажатиями из расчета
    angle -= pointersAngle;
    
    // Считаем текущий угол между нажатиями и сохраняем
    pointersAngle = calculatePointersAngle(touches);
    
    // Добавляем текущий угол к предыдущему значению угла
    angle += pointersAngle;
    rect.style.transform = `rotate(${angle}rad)`;
  }
});

На этом все, посмотреть весь код и потыкать можно тут.

В следующих частях, если тема будет интересна, я реализую оставшиеся жесты (scale, drag) и покажу как реализовать аналоги этих жестов в десктопной версии.

© Habrahabr.ru