Статьи

Создание идеальной карусели, часть 1

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

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

Часть 1 оценит, как Amazon и Netflix реализовали прокрутку. Затем мы реализуем карусель, которую можно прокручивать касанием.

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

Что нужно для того, чтобы карусель была «идеальной»? Он должен быть доступен для:

  • Мышь: она должна предлагать предыдущую и следующую кнопки, которые легко нажимать и не затемнять содержимое.
  • Прикосновение: он должен отслеживать палец, а затем прокручивать с тем же импульсом, что и при поднятии пальца с экрана.
  • Колесо прокрутки: Часто забывают, что Apple Magic Mouse и многие трекпады для ноутбуков обеспечивают плавную горизонтальную прокрутку. Мы должны использовать эти возможности!
  • Клавиатура: многие пользователи предпочитают не использовать мышь или не могут использовать ее для навигации. Важно, чтобы мы сделали нашу карусель доступной, чтобы эти пользователи тоже могли пользоваться нашим продуктом.

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

Во-первых, давайте получим HTML и CSS, необходимые для создания элементарной карусели, разветвив этот CodePen .

Pen использует Sass для предварительной обработки CSS и Babel для переноса ES6 JavaScript. Я также включил Popmotion, к которому можно получить доступ через window.popmotion .

Вы можете скопировать код в локальный проект, если хотите, но вам нужно убедиться, что ваша среда поддерживает Sass и ES6. Вам также необходимо установить Popmotion с помощью npm install popmotion .

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

Я собираюсь использовать фабричную функцию, а не class . Фабричные функции исключают необходимость использования часто вводящего в заблуждение this ключевого слова и упростят код для целей данного руководства.

В вашем редакторе JavaScript добавьте эту простую функцию:

1
2
3
4
function carousel(container) {
}
 
carousel(document.querySelector(‘.container’));

Мы будем добавлять наш специфичный для карусели код внутри этой функции carousel .

Наша первая задача — сделать карусель прокрутки. Есть два способа сделать это:

Очевидным решением было бы установить overflow-x: scroll на слайдер. Это позволило бы прокручивать информацию во всех браузерах, включая сенсорные и горизонтальные устройства мыши.

Однако у этого подхода есть недостатки:

  • Контент вне контейнера не будет виден, что может быть ограничением для нашего дизайна.
  • Это также ограничивает способы, которыми мы можем использовать анимацию, чтобы указать, что мы достигли конца.
  • Настольные браузеры будут иметь некрасивую (хотя и доступную!) Горизонтальную полосу прокрутки.

В качестве альтернативы:

Мы также можем анимировать свойство translateX карусели. Это было бы очень универсально, так как мы могли бы реализовать именно тот дизайн, который нам нравится. translateX также очень производительный, так как в отличие от CSS-свойства left он может обрабатываться графическим процессором устройства.

С другой стороны, нам пришлось бы переопределить функцию прокрутки с помощью JavaScript. Это больше работы, больше кода.

Карусели Amazon и Netflix делают разные компромиссы в решении этой проблемы.

Amazon анимирует left свойство карусели в режиме «рабочего стола». Анимация left — невероятно плохой выбор, так как его изменение вызывает пересчет макета . Это сильно загружает процессор, и на старых машинах будет сложно достичь скорости 60 кадров в секунду.

Тот, кто принял решение оживить left вместо translateX должен быть настоящим идиотом ( спойлер: это был я, еще в 2012 году. В те дни мы не были такими просвещенными).

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

Скриншот Amazon, иллюстрирующий отсутствие дизайнерского кровотечения

Netflix правильно анимирует свойство translateX карусели, и это происходит на всех устройствах. Это позволяет им иметь дизайн, который кровоточит за пределами карусели:

Скриншот карусели Netflix, иллюстрирующий дизайн кровотечения

Это, в свою очередь, позволяет им создать причудливый дизайн, в котором предметы увеличиваются за пределами краев x и y карусели, а окружающие предметы смещаются:

Снимок экрана карусели Netflix с увеличенным изображением

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

Мы можем сделать лучше. Давайте код!

Наш первый шаг — захватить узел .slider . Пока мы на этом, давайте возьмем элементы, которые он содержит, чтобы мы могли выяснить размер слайдера.

1
2
3
4
function carousel(container) {
  const slider = container.querySelector(‘.slider’);
  const items = slider.querySelectorAll(‘.item’);
}

Мы можем определить видимую область слайдера, измерив его ширину:

1
const sliderVisibleWidth = slider.offsetWidth;

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

Используя getBoundingClientRect для измерения left смещения нашего первого элемента и right смещения нашего последнего элемента, мы можем использовать разницу между ними, чтобы найти общую ширину всех элементов.

1
2
3
4
5
function getTotalItemsWidth(items) {
  const { left } = items[0].getBoundingClientRect();
  const { right } = items[items.length — 1].getBoundingClientRect();
  return right — left;
}

После нашего измерения sliderVisibleWidth напишите:

1
const totalItemsWidth = getTotalItemsWidth(items);

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

1
2
const maxXOffset = 0;
const minXOffset = — (totalItemsWidth — sliderVisibleWidth);

После этих измерений мы готовы начать прокрутку нашей карусели.

Popmotion поставляется с рендером CSS для простой и производительной настройки свойств CSS. Он также поставляется с функцией значения, которую можно использовать для отслеживания чисел и, что важно (как мы скоро увидим), для запроса их скорости.

В верхней части вашего файла JavaScript импортируйте их следующим образом:

1
const { css, value } = window.popmotion;

Затем в строке после того, как мы установили minXOffset , создайте CSS-рендеринг для нашего слайдера:

1
const sliderRenderer = css(slider);

И создайте value для отслеживания смещения x нашего слайдера и обновите свойство translateX слайдера, когда оно изменится:

1
const sliderX = value(0, (x) => sliderRenderer.set(‘x’, x));

Теперь переместить ползунок по горизонтали так же просто, как написать:

1
sliderX.set(-100);

Попытайся!

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

01
02
03
04
05
06
07
08
09
10
11
let action;
 
function stopTouchScroll() {
  document.removeEventListener(‘touchend’, stopTouchScroll);
}
 
function startTouchScroll(e) {
  document.addEventListener(‘touchend’, stopTouchScroll);
}
 
slider.addEventListener(‘touchstart’, startTouchScroll, { passive: false });

В нашей функции startTouchScroll мы хотим:

  • Остановите любые другие действия, sliderX .
  • Найдите исходную точку касания.
  • Прослушайте следующее событие touchmove чтобы увидеть, перетаскивает ли пользователь вертикально или горизонтально.

После document.addEventListener добавьте:

1
if (action) action.stop();

Это остановит любые другие действия (такие как физическая импульсная прокрутка, которую мы реализуем в stopTouchScroll ) от перемещения ползунка. Это позволит пользователю немедленно «поймать» ползунок, если он прокручивает мимо элемента или заголовка, на котором он хочет щелкнуть.

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

Мы хотим поделиться этим touchOrigin между обработчиками событий. Так что после let action; добавлять:

1
let touchOrigin = {};

Вернувшись в наш обработчик startTouchScroll , добавьте:

1
2
3
4
5
const touch = e.touches[0];
touchOrigin = {
  x: touch.pageX,
  y: touch.pageY
};

Теперь мы можем добавить слушатель события touchmove к document чтобы определить направление перетаскивания на основе этого touchOrigin :

1
document.addEventListener(‘touchmove’, determineDragDirection);

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

1
2
3
4
5
6
7
function determineDragDirection(e) {
  const touch = e.changedTouches[0];
  const touchLocation = {
    x: touch.pageX,
    y: touch.pageY
  };
}

Popmotion включает несколько полезных калькуляторов для измерения таких вещей, как расстояние между двумя координатами x / y. Мы можем импортировать такие как это:

1
const { calc, css, value } = window.popmotion;

Затем для измерения расстояния между двумя точками используется калькулятор distance :

1
const distance = calc.distance(touchOrigin, touchLocation);

Теперь, если касание переместилось, мы можем отключить этот прослушиватель событий.

1
2
if (!distance) return;
document.removeEventListener(‘touchmove’, determineDragDirection);

Измерьте угол между двумя точками с помощью калькулятора angle :

1
const angle = calc.angle(touchOrigin, touchLocation);

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

01
02
03
04
05
06
07
08
09
10
11
12
function angleIsVertical(angle) {
  const isUp = (
    angle <= -90 + 45 &&
    angle >= -90 — 45
  );
  const isDown = (
    angle <= 90 + 45 &&
    angle >= 90 — 45
  );
 
  return (isUp || isDown);
}

Эта функция возвращает true если заданный угол находится в пределах -90 +/- 45 градусов (прямо вверх) или 90 +/- 45 градусов (прямо вниз). Таким образом, мы можем добавить еще одно условие return , если эта функция возвращает true .

1
if (angleIsVertical(angle)) return;

Теперь мы знаем, что пользователь пытается прокрутить карусель, мы можем начать отслеживать его пальцем. Popmotion предлагает действие указателя, которое выведет координаты x / y мыши или сенсорного указателя.

Сначала импортируйте pointer :

1
const { calc, css, pointer, value } = window.popmotion;

Чтобы отслеживать сенсорный ввод, укажите исходное событие для pointer :

1
action = pointer(e).start();

Мы хотим измерить начальную позицию x нашего указателя и применить любое движение к ползунку. Для этого мы можем использовать преобразователь с именем applyOffset .

Трансформаторы — это чистые функции, которые принимают значение и возвращают его — да — преобразованное. Например: const double = (v) => v * 2 .

1
2
const { calc, css, pointer, transform, value } = window.popmotion;
const { applyOffset } = transform;

applyOffset это карри функция. Это означает, что когда мы вызываем его, он создает новую функцию, которой затем может быть передано значение. Сначала мы вызываем его с помощью номера, из которого мы хотим измерить смещение, в данном случае — текущего значения action.x , и числа, к которому применяется это смещение. В данном случае это наш sliderX .

Так что наша функция applyOffset будет выглядеть так:

1
const applyPointerMovement = applyOffset(action.x.get(), sliderX.get());

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

1
action.output(({ x }) => slider.set(applyPointerMovement(x)));

Карусель теперь перетаскивается на ощупь! Вы можете проверить это с помощью эмуляции устройства в Chrome Developer Tools.

Чувствую себя немного дерганным, верно? Возможно, вы уже сталкивались с прокруткой, которая выглядит так: раньше вы поднимаете палец, и прокрутка останавливается. Или прокрутка останавливается, а затем появляется небольшая анимация, чтобы имитировать продолжение прокрутки.

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

Во-первых, добавьте его в наш постоянно растущий список импорта:

1
const { calc, css, physics, pointer, value } = window.popmotion;

Затем в конце нашей функции stopTouchScroll добавьте:

1
2
3
4
5
6
7
8
9
if (action) action.stop();
 
action = physics({
  from: sliderX.get(),
  velocity: sliderX.getVelocity(),
  friction: 0.2
})
  .output((v) => sliderX.set(v))
  .start();

Здесь, from и velocity устанавливаются с текущим значением и скоростью sliderX . Это гарантирует, что наше физическое моделирование имеет те же начальные начальные условия, что и перетаскивание пользователя.

friction устанавливается как 0.2 . Трение задается как значение от 0 до 1 , где 0 — отсутствие трения вообще, а 1 — абсолютное трение. Попробуйте поиграть с этим значением, чтобы увидеть, какое изменение он вносит в «ощущение» карусели, когда пользователь перестает перетаскивать.

Меньшие числа заставят это чувствовать себя легче, и большие числа сделают движение более тяжелым. Для движения с прокруткой, я чувствую, что 0.2 ударил хороший баланс между неустойчивым и вялым.

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

Для этой работы есть другой трансформатор, clamp . Это также карри функция, то есть если мы вызовем ее с минимальным и максимальным значением, скажем, 0 и 1 , она вернет новую функцию. В этом примере новая функция будет ограничивать любое число, данное ей, от 0 до 1 :

1
clamp(0, 1)(5);

Во-первых, импортный clamp :

1
const { applyOffset, clamp } = transform;

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

1
const clampXOffset = clamp(minXOffset, maxXOffset);

Мы собираемся изменить два output мы установили для наших действий, используя некоторую легкую функциональную композицию с преобразователем pipe .

Когда мы вызываем функцию, мы пишем это так:

1
foo(0);

Если мы хотим передать вывод этой функции другой функции, мы могли бы написать это так:

1
bar(foo(0));

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

С помощью pipe мы можем составить новую функцию из foo и bar которую мы можем использовать повторно:

1
2
const foobar = pipe(foo, bar);
foobar(0);

Это также написано в естественном порядке начала -> конца, который облегчает следовать. Мы можем использовать это, чтобы составить applyOffset и applyOffset в одной функции. Импортная pipe :

1
const { applyOffset, clamp, pipe } = transform;

Замените output обратный вызов нашего pointer на:

1
2
3
4
5
6
pipe(
  ({ x }) => x,
  applyOffset(action.x.get(), sliderX.get()),
  clampXOffset,
  (v) => sliderX.set(v)
)

И замените обратный вызов physics на:

1
pipe(clampXOffset, (v) => sliderX.set(v))

Этот вид функциональной композиции может довольно аккуратно создавать описательные, пошаговые процессы из меньших, многократно используемых функций.

Теперь, когда вы перетаскиваете карусель, она не выходит за пределы своих границ.

Резкая остановка не очень удовлетворяет. Но это проблема для более поздней части!

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

Вы можете получить закомментированную версию «истории на данный момент» в этом CodePen .

В следующих выпусках мы рассмотрим:

  • прокрутка колесиком мыши
  • переосмысление карусели при изменении размеров окна
  • нумерация страниц с клавиатурой и мышью
  • восхитительные прикосновения, с помощью весенней физики

Ждем Вас там!