Статьи

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

Это третья и последняя часть нашей серии уроков «Создай идеальную карусель». В первой части мы оценили карусели на Netflix и Amazon, двух самых популярных каруселях в мире. Мы настроили нашу карусель и внедрили сенсорную прокрутку.

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

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

Вы можете выбрать, где мы остановились с этим CodePen .

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

Хорошей новостью является то, что это обычно легко реализовать! Фактически, браузеры делают большую часть работы за нас. Серьезно: попробуйте листать карусель, которую мы сделали. Поскольку мы использовали семантическую разметку, вы уже можете!

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

Это нормально, и, на мой взгляд, это можно квалифицировать как «исправное», хотя и не совсем восхитительное.

Карусель Netflix также работает таким образом. Но поскольку большинство их заголовков загружены лениво, и они также пассивно доступны с клавиатуры (то есть они не написали никакого кода специально для его обработки), мы не можем на самом деле выбрать какие-либо заголовки, кроме тех, которые у нас есть. уже загружен. Это также выглядит ужасно:

Доступность клавиатуры

Мы можем сделать лучше.

Для этого мы собираемся прослушать событие focus которое срабатывает на любом объекте в карусели. Когда элемент получает фокус, мы собираемся запросить его о его позиции. Затем мы проверим это по отношению к sliderX и sliderVisibleWidth чтобы увидеть, находится ли этот элемент в видимом окне. Если это не так, мы будем разбивать на страницы, используя тот же код, который мы написали во второй части.

В конце функции carousel добавьте прослушиватель этого события:

1
slider.addEventListener(‘focus’, onFocus, true);

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

Над нашим растущим блоком слушателей событий добавьте функцию onFocus :

1
2
function onFocus(e) {
}

Мы будем работать в этой функции до конца этого раздела.

Нам нужно измерить left и right смещение элемента и проверить, находится ли какая-либо точка за пределами видимой в данный момент области.

Элемент предоставляется target параметром события, и мы можем измерить его с помощью getBoundingClientRect :

1
const { left, right } = e.target.getBoundingClientRect();

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

1
const carouselLeft = container.getBoundingClientRect().left;

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

1
2
3
4
5
if (left < carouselLeft) {
  gotoPrev();
} else if (right > carouselLeft + sliderVisibleWidth) {
  gotoNext();
}

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

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

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

Теперь, в начале вашей функции carousel , сразу после строки, где мы определяем progressBar , мы хотим заменить три из этих постоянных измерений на let , потому что мы собираемся изменить их, когда изменится область просмотра:

1
2
3
4
5
6
const totalItemsWidth = getTotalItemsWidth(items);
const maxXOffset = 0;
 
let minXOffset = 0;
let sliderVisibleWidth = 0;
let clampXOffset;

Затем мы можем переместить логику, которая ранее рассчитывала эти значения, в новую функцию measureCarousel :

1
2
3
4
5
function measureCarousel() {
  sliderVisibleWidth = slider.offsetWidth;
  minXOffset = — (totalItemsWidth — sliderVisibleWidth);
  clampXOffset = clamp(minXOffset, maxXOffset);
}

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

1
measureCarousel();

Карусель должна работать точно так же, как и раньше. Чтобы обновить размер окна, мы просто добавляем прослушиватель событий в самом конце нашей функции carousel :

1
window.addEventListener(‘resize’, measureCarousel);

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

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

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

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

По сути, функция debounce говорит: «Запускайте эту функцию, только если она не была вызвана в течение x миллисекунд». Вы можете прочитать больше о debounce на отличном учебнике Дэвида Уолша , а также получить пример кода.

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

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

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

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

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

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

В функции defineDragDirection у нас есть этот код:

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

Чуть выше, давайте создадим две наши пружины, по одной на каждый предел прокрутки карусели:

1
2
3
const elasticity = 5;
const tugLeft = nonlinearSpring(elasticity, maxXOffset);
const tugRight = nonlinearSpring(elasticity, minXOffset);

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

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

1
2
3
4
5
const applySpring = (v) => {
  if (v > maxXOffset) return tugLeft(v);
  if (v < minXOffset) return tugRight(v);
  return v;
};

В clampXOffset выше коде мы можем заменить clampXOffset на applySpring . Теперь, если вы потяните ползунок за пределы его границ, он потянет назад!

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

physics действие способно также моделировать пружины. Нам просто нужно снабдить его spring и свойствами.

В stopTouchScroll переместите существующую инициализацию physics прокрутки в часть логики, которая гарантирует, что мы находимся в пределах прокрутки:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
const currentX = sliderX.get();
 
if (currentX < minXOffset || currentX > maxXOffset) {
   
} else {
  action = physics({
    from: currentX,
    velocity: sliderX.getVelocity(),
    friction: 0.2
  }).output(pipe(
    clampXOffset,
    (v) => sliderX.set(v)
  )).start();
}

В первом предложении оператора if мы знаем, что ползунок находится за пределами прокрутки, поэтому мы можем добавить нашу пружину:

1
2
3
4
5
6
7
action = physics({
  from: currentX,
  to: (currentX < minXOffset) ?
  spring: 800,
  friction: 0.92
}).output((v) => sliderX.set(v))
  .start();

Мы хотим создать весну, которая будет чувствительной и отзывчивой. Я выбрал относительно высокое значение spring чтобы иметь жесткое «растяжение», и я уменьшил friction до 0.92 чтобы позволить небольшой отскок. Вы можете установить это в 1 чтобы полностью исключить отскок.

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

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

Во-первых, мы хотим отключить кнопки нумерации страниц, когда предел достигнут. Давайте сначала добавим правило CSS, которое стилизует кнопки, чтобы показать, что они disabled . В правиле button добавьте:

1
2
3
4
5
transition: background 200ms linear;
 
&.disabled {
  background: #eee;
}

Мы используем здесь класс вместо более семантического атрибута disabled потому что мы по-прежнему хотим фиксировать события щелчка, которые, как следует из названия, disabled , disabled .

Добавьте этот disabled класс к кнопке Prev, потому что каждая карусель начинает жизнь со смещением 0 :

1
<button class=»prev disabled»>Prev</button>

На вершине carousel создайте новую функцию с именем checkNavButtonStatus . Мы хотим, чтобы эта функция просто проверила предоставленное значение с minXOffset и maxXOffset и соответственно установила класс disabled кнопки:

01
02
03
04
05
06
07
08
09
10
11
12
13
function checkNavButtonStatus(x) {
  if (x <= minXOffset) {
    nextButton.classList.add(‘disabled’);
  } else {
    nextButton.classList.remove(‘disabled’);
 
    if (x >= maxXOffset) {
      prevButton.classList.add(‘disabled’);
    } else {
      prevButton.classList.remove(‘disabled’);
    }
  }
}

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

Поэтому нам нужно быть более разборчивыми в отношении того, когда вызывать эту функцию. Кажется разумным назвать это:

В последней строке onWheel добавьте checkNavButtonStatus(newX); ,

В последней строке goto добавьте checkNavButtonStatus(targetX); ,

И, наконец, в конце stopTouchScroll и в пункте прокрутки импульса (код внутри else ) stopTouchScroll замените:

1
(v) => sliderX.set(v)

С:

1
2
3
4
(v) => {
  sliderX.set(v);
  checkNavButtonStatus(v);
}

Теперь все, что осталось, это изменить gotoPrev и gotoNext чтобы проверить gotoNext их триггерной кнопки на disabled и разбивать на страницы, только если он отсутствует:

1
2
3
4
5
6
7
const gotoNext = (e) => !e.target.classList.contains(‘disabled’)
  ?
  : notifyEnd(-1, maxXOffset);
 
const gotoPrev = (e) => !e.target.classList.contains(‘disabled’)
  ?
  : notifyEnd(1, minXOffset);

Функция notifyEnd — это просто еще одна physics пружина, и она выглядит так:

01
02
03
04
05
06
07
08
09
10
11
12
function notifyEnd(delta, targetOffset) {
  if (action) action.stop();
  action = physics({
    from: sliderX.get(),
    to: targetOffset,
    velocity: 2000 * delta,
    spring: 300,
    friction: 0.9
  })
    .output((v) => sliderX.set(v))
    .start();
}

Поиграйте с этим и снова настройте параметры physics по своему вкусу.

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

1
progressBarRenderer.set(‘scaleX’, progress);

С:

1
progressBarRenderer.set(‘scaleX’, Math.max(progress, 0));

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

С одностраничными приложениями веб-сайты работают дольше в сеансе пользователя. Часто, даже когда «страница» изменяется, мы по-прежнему выполняем ту же среду выполнения JS, что и при начальной загрузке. Мы не можем полагаться на чистую доску каждый раз, когда пользователь щелкает ссылку, а это значит, что мы должны убирать за собой, чтобы слушатели событий не запускали мертвые элементы.

В React этот код помещается в метод componentWillLeave . Vue использует beforeDestroy . Это чистая реализация JS, но мы все же можем предоставить метод destroy, который одинаково работал бы в любой среде.

Пока что наша функция carousel ничего не вернула. Давайте изменим это.

Сначала измените последнюю строку, строку, которая вызывает carousel , на:

1
const destroyCarousel = carousel(document.querySelector(‘.container’));

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

1
2
3
4
5
6
7
8
return () => {
  container.removeEventListener(‘touchstart’, startTouchScroll);
  container.removeEventListener(‘wheel’, onWheel);
  nextButton.removeEventListener(‘click’, gotoNext);
  prevButton.removeEventListener(‘click’, gotoPrev);
  slider.removeEventListener(‘focus’, onFocus);
  window.removeEventListener(‘resize’, measureCarousel);
};

Теперь, если вы вызываете destroyCarousel и пытаетесь играть с каруселью, ничего не происходит! Это почти немного грустно видеть это так.

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

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