Приветствую, друзья технологии!

Сегодня в мире постоянно меняющихся технологий и уникальных разработок смартфонов, планшетов и других устройств, оказаться "в тренде" - это как настоящее искусство. Каждый из нас хочет использовать устройства, которые позволяют нам легко и интуитивно взаимодействовать с миром цифровых возможностей. Одной из фантастических новинок, которая взрывает сознание пользователей и разработчиков, является свайп-сайдбар – это гениальное решение для эффективной навигации и управления контентом!

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

В этой увлекательной статье мы окунемся в мир свайп-сайдбаров, расскажу, как они работают, как создать свой собственный сервис для свайпов, прикрутим все это дело к Vue + Typescript. Не волнуйтесь, если вы новичок в программировании или разработке, я проведу вас через каждый шаг, чтобы вы могли освоить это волшебство свайпов!

Немного логики

Представьте себе, что весь этот процесс — это настоящее путешествие в мир магии и волшебства, где вы становитесь волшебником своего приложения или веб-страницы, способным управлять им лишь прикосновением вашего пальца.

Ваш сервис на TypeScript, будь-то мудрый старец или мудрая старица, отвечает за отслеживание каждого движения пальца по горизонтали и вертикали. И когда он распознает ваше магическое движение, он возвращает это знание Vue компоненту, чтобы выполнить заклинание открытия сайдбара!

В Vue компоненте, ваша роль — это как раз таинственный архитектор, который создает уникальный контейнер для отображения сайдбара. Вы создаете экземпляр класса сервиса для свайпа в любом месте, что пожелаете. Теперь ваш компонент обладает магической силой, способной обрабатывать жесты касания!

И вот, когда пользователь делает первый шаг, касаясь вашего компонента, вы ловите этот момент, как настоящий охотник за тенями, и активируете обработчик touchstart. Как будто вы дотрагиваетесь до магической сферы, которая раскрывается перед вами!

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

Когда вашему взору предстанет финальное место, куда ведет жест, вы с умением оракула применяете заклинание touchend. Теперь, когда ваш сервис TypeScript передаст всю информацию о касании, вы открываете свой сокровищницу — ваш сайдбар!

Таким образом, волшебное сочетание сервиса TypeScript и компонента Vue дает вам возможность создать увлекательное путешествие для пользователей, где они могут легко и интуитивно взмахом пальца открывать и закрывать сайдбар. Вы — настоящий маг в своем роде, привносящий чудеса в каждое касание!

К главному

Ну, надеюсь я вас заинтересовал таким загадочным текстом :) Теперь перейдем к самому коду!:

В коде определено два класса: RootSwipeable и SwipeableService.

RootSwipeable - это класс, представляющий простой объект, отвечающий за обработку свайпов. Этот класс не взаимодействует с непосредственно с интерфейсом пользователя и предназначен для внутреннего использования в SwipeableService.

RootSwipeable содержит следующие поля:

p_offset: число, представляющее смещение (порог), которое определяет, насколько далеко нужно свайпнуть, чтобы считать это свайпом влево или вправо.

p_previous: объект с полем x, которое содержит предыдущее положение по оси X. Изначально установлено в null.

  private readonly p_offset: number; // Минимальное смещение для определения свайпа
  private p_previous: { x: number | null }; // Предыдущее положение по оси X

  constructor({ offset }: { offset: number }) {
    this.p_offset = offset; // Установка минимального смещения
    this.p_previous = {x: null}; // Инициализация предыдущего положения
  }

updatePreviousState - это метод класса RootSwipeable, который обновляет значение p_previous.x, записывая в него текущее положение по оси X из переданного события TouchEvent.

  // Обновление предыдущего положения на основе события TouchEvent
  updatePreviousState(event: TouchEvent): void {
    this.p_previous.x = event.changedTouches[0].screenX;
  }

init - это метод класса RootSwipeable, предназначенный для инициализации начального состояния свайпа. Если p_previous.x равно null, вызывается метод updatePreviousState с переданным событием TouchEvent.

  // Инициализация начального состояния свайпа
  init(event: TouchEvent): void {
    !this.p_previous.x && this.updatePreviousState(event);
  }

handleGesture - это метод класса RootSwipeable, который обрабатывает жесты (свайпы) на основе изменений TouchEvent. Он принимает объект со свойствами onLeft и onRight, которые представляют собой функции обратного вызова (колбэки) для обработки свайпов влево и вправо соответственно.

Метод handleGesture сравнивает текущее положение по оси X с предыдущим положением p_previous.x. Если разница между ними превышает значение p_offset, то считается, что был совершен свайп влево или вправо. В зависимости от направления свайпа вызывается соответствующий колбэк onLeft или onRight, и обновляется значение p_previous.x на текущее положение.

  // Обработка жестов (свайпов) на основе изменений TouchEvent
  handleGesture(event: TouchEvent, {
    onLeft = () => {}, // Колбэк для свайпа влево
    onRight = () => {} // Колбэк для свайпа вправо
  }: {
    onLeft: () => void,
    onRight: () => void
  }) {
    if (!this.p_previous.x) {
      return; // Если предыдущее положение не определено, выход из метода
    }
    let screenX = event.changedTouches[0].screenX; // Текущее положение по оси X

    // Если смещение вправо превышает минимальное значение
    if (this.p_previous.x + this.p_offset < screenX) {
      this.updatePreviousState(event); // Обновление предыдущего положения
      onRight(); // Вызов колбэка для свайпа вправо
      return;
    }

    // Если смещение влево превышает минимальное значение
    if (this.p_previous.x - this.p_offset > screenX) {
      this.updatePreviousState(event); // Обновление предыдущего положения
      onLeft(); // Вызов колбэка для свайпа влево
    }
  }

Метод kill обнуляет значение p_previous.x, что означает завершение жеста.
И теперь подрезюмируем весь класс:

class RootSwipeable {
  private readonly p_offset: number; // Минимальное смещение для определения свайпа
  private p_previous: { x: number | null }; // Предыдущее положение по оси X

  constructor({ offset }: { offset: number }) {
    this.p_offset = offset; // Установка минимального смещения
    this.p_previous = {x: null}; // Инициализация предыдущего положения
  }

  // Обновление предыдущего положения на основе события TouchEvent
  updatePreviousState(event: TouchEvent): void {
    this.p_previous.x = event.changedTouches[0].screenX;
  }

  // Инициализация начального состояния свайпа
  init(event: TouchEvent): void {
    !this.p_previous.x && this.updatePreviousState(event);
  }

  // Обработка жестов (свайпов) на основе изменений TouchEvent
  handleGesture(event: TouchEvent, {
    onLeft = () => {}, // Колбэк для свайпа влево
    onRight = () => {} // Колбэк для свайпа вправо
  }: {
    onLeft: () => void,
    onRight: () => void
  }) {
    if (!this.p_previous.x) {
      return; // Если предыдущее положение не определено, выход из метода
    }
    let screenX = event.changedTouches[0].screenX; // Текущее положение по оси X

    // Если смещение вправо превышает минимальное значение
    if (this.p_previous.x + this.p_offset < screenX) {
      this.updatePreviousState(event); // Обновление предыдущего положения
      onRight(); // Вызов колбэка для свайпа вправо
      return;
    }

    // Если смещение влево превышает минимальное значение
    if (this.p_previous.x - this.p_offset > screenX) {
      this.updatePreviousState(event); // Обновление предыдущего положения
      onLeft(); // Вызов колбэка для свайпа влево
    }
  }

  // Обнуление предыдущего положения, завершение свайпа
  kill() {
    this.p_previous = {x: null};
  }
}

SwipeableService - это класс, который предоставляет обертку над RootSwipeable для удобного использования в интерфейсе пользователя.

  root: RootSwipeable; // Экземпляр класса RootSwipeable
  pageWidth: number; // Ширина страницы
  minSwipe: number; // Минимальное значение для определения свайпа
  touchstart: { x: number }; // Начальное положение пальца по оси X
  touchend: { x: number }; // Конечное положение пальца по оси X

  constructor({offset}: { offset: number }) {
    this.root = new RootSwipeable({ offset }); // Создание экземпляра RootSwipeable
    this.pageWidth = window.innerWidth || document.body.clientWidth; // Определение ширины страницы
    this.minSwipe = Math.max(1, Math.floor(0.01 * (this.pageWidth))); // Вычисление минимального значения для свайпа
    this.touchstart = {x: 0}; // Инициализация начального положения
    this.touchend = {x: 0}; // Инициализация конечного положения
  }

touchStart - это метод класса SwipeableService, который вызывается при событии touchstart. Он инициализирует начальное состояние свайпа, вызывая init у RootSwipeable и записывая начальное положение по оси X в touchstart.x.

// Обработчик события touchstart
  touchStart(event: TouchEvent) {
    this.root.init(event); // Инициализация начального состояния свайпа
    this.touchstart.x = event.changedTouches[0].screenX; // Сохранение начального положения пальца
  }

touchMove - это метод класса SwipeableService, который вызывается при событии touchmove. Он обновляет touchend.x и затем вызывает метод handleGesture у RootSwipeable с переданными колбэками onLeft и onRight.

// Обработчик события touchmove
  touchMove(event: TouchEvent, {
    onLeft = () => {}, // Колбэк для свайпа влево
    onRight = () => {} // Колбэк для свайпа вправо
  }) {
    this.touchend.x = event.changedTouches[0].screenX; // Сохранение конечного положения пальца
    this.handleGesture(event, {onLeft, onRight}); // Вызов метода handleGesture для обработки свайпа
  }

touchEnd - это метод класса SwipeableService, который вызывается при событии touchend. Он завершает жест, вызывая kill у RootSwipeable и обнуляя touchstart.x и touchend.x.

export default class SwipeableService {
  root: RootSwipeable; // Экземпляр класса RootSwipeable
  pageWidth: number; // Ширина страницы
  minSwipe: number; // Минимальное значение для определения свайпа
  touchstart: { x: number }; // Начальное положение пальца по оси X
  touchend: { x: number }; // Конечное положение пальца по оси X

  constructor({offset}: { offset: number }) {
    this.root = new RootSwipeable({ offset }); // Создание экземпляра RootSwipeable
    this.pageWidth = window.innerWidth || document.body.clientWidth; // Определение ширины страницы
    this.minSwipe = Math.max(1, Math.floor(0.01 * (this.pageWidth))); // Вычисление минимального значения для свайпа
    this.touchstart = {x: 0}; // Инициализация начального положения
    this.touchend = {x: 0}; // Инициализация конечного положения
  }

  // Обработчик события touchstart
  touchStart(event: TouchEvent) {
    this.root.init(event); // Инициализация начального состояния свайпа
    this.touchstart.x = event.changedTouches[0].screenX; // Сохранение начального положения пальца
  }

  // Обработчик события touchmove
  touchMove(event: TouchEvent, {
    onLeft = () => {}, // Колбэк для свайпа влево
    onRight = () => {} // Колбэк для свайпа вправо
  }) {
    this.touchend.x = event.changedTouches[0].screenX; // Сохранение конечного положения пальца
    this.handleGesture(event, {onLeft, onRight}); // Вызов метода handleGesture для обработки свайпа
  }

  // Обработчик жестов (свайпов) на основе изменений TouchEvent
  handleGesture(event: TouchEvent,
                {onLeft, onRight}:
                  {
                    onLeft: (e: TouchEvent, x: number) => void,
                    onRight: (e: TouchEvent, x: number) => void
                  }
  ) {
    let x = this.touchend.x - this.touchstart.x; // Вычисление смещения по оси X
    if (Math.abs(x) > this.minSwipe) { // Если смещение превышает минимальное значение для свайпа
      this.root.handleGesture(event, {
        onRight: () => onRight(event, x), // Вызов колбэка для свайпа вправо с параметрами события и смещения
        onLeft: () => onLeft(event, x) // Вызов колбэка для свайпа влево с параметрами события и смещения
      });
    }
  }

  // Обработчик события touchend
  touchEnd() {
    this.root.kill(); // Завершение свайпа
    this.touchstart = {x: 0}; // Обнуление начального положения
    this.touchend = {x: 0}; // Обнуление конечного положения
  }
}

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

В принципе, на этом наш сервис готов, осталось только его прикрутить к Vue компоненту

Связываем наш сервис с Vue

Небольшая отметочка - сайдбар я задумывал слева, поэтому вся логика будет относиться к случаю, когда сайдбар слева :) Но не трудно переписать и на правый, просто инвертировать обработчики left, right.

Для начала напишем структуру для компонента:

<template>
  <div class="main">
    <div class="main-page">
      <main class="main-page_content">
        <div class="sidebar"
             style="transition: all .2s linear"
             ref="sidebar"
             @touchstart="touchStart($event)"
             @touchend="touchEnd($event)"
             @touchmove="touchMove($event)">
          <button style="background:#2654ac; padding: 5px; color:#fff;" class="toggler" @click="toggleSidebar($event)">X</button>
        </div>
      </main>
    </div>
  </div>
</template>

И да, у Vue под капотом уже есть встроенная функция для работы с touch событиями, нам остается только пришабашить наш новоиспеченный сервис как нибудь к этим событиям. Делается это довольно несложно, логика никакая не меняется, т.к. нужно просто вызывать методы класса сервиса. Добавятся лишь стили, которые будут убирать или выдвигать наш сайдбар!

Нам потребуется всего лишь 3 переменные:

swipeable: new Swipeable({offset: 2}), // Экземпляр класса
currentPosition: 0, // текущая позиция касания
isSidebarOpened: true, // открыт ли сайдбар?

Как я и говорил выше, логика в плане касаний не меняется, мы просто вызываем те же самые функции, только уже с прикрутом touch событий по компоненту:

    // как раз те самые функции, которые @touchStart="touchStart($event)"
    touchStart(e) { 
      this.swipeable.touchStart(e)
    },
    touchMove(e) {
      this.swipeable.touchMove(e, {onLeft: this.handleLeftTouch, onRight: this.handleRightTouch})
    },
    touchEnd(e) {
      this.currentPosition = parseInt(e.target.style.left, 10)
      this.swipeable.touchEnd(e)
    },

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

    handleLeftTouch(e, x) {
      const blockInstance = e.target;
      const blockInstanceWidth = blockInstance.offsetWidth
      if (x + this.currentPosition > 0) {
        // Если свайп пытается выйти за пределы
        blockInstance.style.left = `0`
      } else if (Math.abs(x + this.currentPosition) >  blockInstanceWidth / 2) {
        // Если наш свайп был больше чем на половину, закроем его
        blockInstance.style.left = `-${blockInstanceWidth}px`
        this.isSidebarOpened = false;
      } else {
        // В остальных случаях просто посмотрим, на сколько свайпнули и изменим положение сайдбара
        blockInstance.style.left = `${x + this.currentPosition}px`
        console.log('свайп на ', x, 'px влево')
      }
    },
    handleRightTouch(e, x) {
      const blockInstance = e.target;
      // Та же проверка на пределы
      if (x + this.currentPosition > 0) {
        blockInstance.style.left = `0`
        return;
      }
      blockInstance.style.left = `${x + this.currentPosition}px`
      console.log('свайп на ', x, 'px вправо')
    },

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

    toggleSidebar(e) {
      const sidebarNode = e.target.offsetParent

      if (this.isSidebarOpened) {
        sidebarNode.style.left = `-${sidebarNode.offsetWidth}px`
        this.isSidebarOpened = false;
      } else {
        sidebarNode.style.left = "0";
        this.currentPosition = 0;
        this.isSidebarOpened = true;
      }
    },

Вот и подошли к итоговой версии кода! Поздравляю всех, кто дочитал до этого места :)

Демонстрация

Полный код на моем гитхабе (в нем также есть и JS версия)

Вот и весь секрет свайпов!

Надеюсь статья была полезна для вас! Удачи!

Комментарии (2)


  1. deamondz
    02.08.2023 22:54

    style.left кмк лучше заменить на style.transform = `translateX(${...})`;, так изменение UI будет более плавным


  1. modelair
    02.08.2023 22:54

    хорошо, но есть замечания.

    здесь явно просится vue-директива, а не все эти @touch

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

    отрефакторьте код(зачем смешивать camelCase и snake_case), оформите npm-пакетом и добавьте демо.