Advertisement
  1. Web Design
  2. HTML/CSS
  3. Animation

Создание безупречной карусели. Часть 2

Scroll to top
Read Time: 12 min
This post is part of a series called Create the Perfect Carousel.
Create the Perfect Carousel, Part 1
Create the Perfect Carousel, Part 3

() translation by (you can also view the original English article)

Добро пожаловать в серии руководств "Создание безупречной карусели". Мы создаем удобную и очаровательную карусель при помощи возможностей Popmotion по симуляции физических явлений (* вроде скорости, ускорения, трения, усилия пружины. Здесь и далее примеч. пер.), JavaScript, твининга (* построение промежуточных отображений; плавный переход от одного значения к другому) и отслеживания действий пользователя.

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

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

Вы можете освежить в памяти, где мы остановились, посетив этот Pen (* фрагмент кода) на Codepen.

Добавляем возможность прокрутки контента по горизонтали

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

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

1
container.addEventListener('wheel', onWheel);

Ниже вашего обработчика событий startTouchScroll добавьте заглушку (* небольшой фрагмент программного кода, который либо ничего не делает, либо печатает сообщение типа "FileOpenStub", либо подставляет необходимые для отладки данные и т. д.; вставляется в разрабатываемую программу вместо ещё не написанной функции (драйвера, модуля, подсистемы, ...)) под названием onWheel:

1
function onWheel(e) {
2
  console.log(e.deltaX)
3
}

Теперь, если вы воспользуетесь колесиком прокрутки, находясь в карусели, и проверите свою консоль, то увидите там данные о расстоянии, на которое мы переместились по оси Х при помощи колесика.

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

1
const angle = calc.angle({
2
  x: e.deltaX,
3
  y: e.deltaY
4
});
5
6
if (angleIsVertical(angle)) return;
7
8
e.stopPropagation();
9
e.preventDefault();

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

1
const newX = clampXOffset(
2
  sliderX.get() + - e.deltaX
3
);
4
sliderX.set(newX);

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

Отступление по поводу ограничения частоты генерации событий, возникающих при прокручивании

В любом хорошем руководстве, в котором работают с событиями, возникающими при действиях со стороны пользователя, будет объяснена важность ограничения частоты возникновения тех событий. Это так, поскольку события, возникающие при прокрутке, действиях с мышкой и действиях, выполняемых пользователем касаниями пальцев по экрану, все могут генерироваться быстрее частоты смены кадров (* скорость сканирования или вывода на экран видеокадров - дискретных изображений (30 кадр/с в стандарте NTSC и 25 кадр/с в стандарте PAL/SECAM)) устройства.

Вам не нужно, чтобы выполнялась лишняя ресурсоемкая работа вроде рендеринга (* в КГА – процесс визуализации – построения графической сцены или трёхмерного объекта по его описанию и последующего отображения результата в растровую цифровую форму. Во время рендеринга происходит наложение текстур, освещения, затенения, тумана и др.) карусели дважды за один фрейм, поскольку это пустая растрата ресурсов и прямая дорога к медленно реагирующему интерфейсу.

В этом руководстве не затрагивался этот вопрос, поскольку рендереры, предоставляемые Popmotion, используют Framesync, крошечный планировщик обработки заданий, синхронизированный с фреймами. Это означает, что вы могли бы вызвать (v) => sliderRenderer.set('x', v) множество раз подряд, а ресурсоемкий рендеринг был бы выполнен только однажды, в следующем фрейме.

Пагинация

На этом все с прокруткой. Теперь нам необходимо немного оживить навигационные кнопки, с которыми до сего времени не работали.

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

Как должна работать пагинация?

Имеется две явные стратегии, которыми мы могли бы воспользоваться для реализации возможности пагинации по элементам карусели: стратегия, согласно которой переход происходит постепенно по всем элементам, или стратегия, согласно которой переход происходит по первым частично отображенным (* неотчётливым) элементам. Только одна из них верна, однако поскольку я видел, как часто используется другая, то я подумал, что стоило бы объяснить, почему последняя неверна.

1. Стратегия, согласно которой переход происходит постепенно по всем элементам

Item By Item Example

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

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

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

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

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

The First Obscured ItemThe First Obscured ItemThe First Obscured Item

При этом подходе происходит поиск первого частично отображенного элемента в направлении, в котором мы хотим перемещать карусель, получение его отступа по оси Х и прокручивание к нему.

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

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

Слушатели событий

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

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

1
const nextButton = container.querySelector('.next');
2
const prevButton = container.querySelector('.prev');

Затем внизу функции carousel добавьте слушатели событий:

1
nextButton.addEventListener('click', gotoNext);
2
prevButton.addEventListener('click', gotoPrev);

Наконец, выше вашего блока со слушателями событий добавьте собственно функции:

1
function goto(delta) {
2
}
3
4
const gotoNext = () => goto(1);
5
const gotoPrev = () => goto(-1);

goto – функция, в которой будет реализована вся логика для пагинации. Функция просто принимает число, которое представляет необходимое нам направление перемещения по карусели. gotoNext и gotoPrev просто вызывают эту функцию, передавая 1 или -1 соответственно.

Выбор «страницы»

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

Вместо этого при вызове функции goto мы узнаем направление, указанное в delta, и получим первый частично отображенный элемент. Он станет первым элементом нашей следующей страницы.

На первом этапе нам необходимо получить текущий отступ по оси Х нашей карусели и воспользоваться им вместе с полной видимой шириной карусели для вычисления «идеального» отступа, на который мы бы хотели ее прокрутить. Идеальный отступ – тот, на который мы бы прокрутили карусель, если бы не были заинтересованы содержимым ее видимых элементов. Он предоставляет нам подходящее место для начала поиска нашего первого элемента.

1
const currentX = sliderX.get();
2
let targetX = currentX + (- sliderVisibleWidth * delta);

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

1
const clampedX = clampXOffset(targetX);
2
3
targetX = (targetX === clampedX)
4
  ? findClosestItemOffset(targetX, delta)
5
  : clampedX;

Нахождение ближайшего частично отображенного элемента

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

Над функцией goto добавьте функцию findClosestItemOffset, упомянутую в последнем фрагменте кода:

1
function findClosestItem(targetX, delta) {
2
}

Для начала нам необходимо узнать ширину наших элементов и интервалов между ними. Метод Element.getBoundingClientRect() может предоставить нам эту информацию. Для определения ширины мы просто измеряем ширину первого элемента. Для того чтобы вычислить ширину интервала между элементами, мы можем измерить right (* правый) отступ первого элемента и left (* левый) отступ второго, а затем вычесть из последнего первый:

1
const { right, width } = items[0].getBoundingClientRect();
2
const spacing = items[1].getBoundingClientRect().left - right;

Теперь, при наличии значений переменных targetX и delta, переданных в функцию (* findClosestItem), у нас имеются все данные, необходимые для быстрого определения отступа, к которому нам необходимо прокрутить карусель.

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

1
const totalItems = Math.abs(targetX) / (width + spacing);

Затем округляем с повышением числа или с понижением числа, в зависимости от направления пагинации (указанного в delta). В результате получится число полных элементов, которые мы можем уместить.

1
const totalCompleteItems = delta === 1
2
  ? Math.floor(totalItems)
3
  : Math.ceil(totalItems);

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

1
return 0 - totalCompleteItems * (width + spacing);

Анимация пагинации

Теперь, когда у нас имеется значение targetX, мы можем санимировать карусель к нему. Для этого мы воспользуемся рабочей лошадкой веб-анимаций – твином (* от англ. tween).

Для тех, кто не в курсе, tween – сокращенное от between (* от англ. между). При помощи твина изменяются значения свойств в течение указанного периода времени. Если вы пользовались CSS-переходами, то знайте, что это одно и то же.

Имеется ряд преимуществ (и недостатков!) использования JavaScript вместо CSS для реализации твинов. Поскольку мы также анимируем sliderX при помощи возможностей Popmotion по симуляции физических явлений и на основании выполненных пользователем действий, то в этом случае нам будет легче придерживаться использования этой технологии (* JavaScript) и для реализации твинов.

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

Для начала нам необходимо импортировать tween из Popmotion:

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

В конце нашей функции goto мы можем добавить наш твин, при помощи которого значение изменяется от значения currentX к значению targetX:

1
tween({
2
  from: currentX,
3
  to: targetX,
4
  onUpdate: sliderX
5
}).start();

По умолчанию в Popmotion в качестве значения duration задано 300 миллисекунд и easing.easeOut в качестве значения ease (* от англ. сглаживать; задавать вариант изменения анимации). Эти значения были выбраны специально для придания анимациям, возникающим при взаимодействии пользователя с интерфейсом, отзывчивого характера (* вроде ощущения замедления прокручивания карусели), однако вы запросто можете поэкспериментировать с этими значениями и посмотреть, получится ли у вас что-то более подходящее для вашего бренда.

Индикатор хода процесса

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

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

Вы еще не видели индикатор, поскольку мы изначально задали для него правило transform: scaleX(0). Мы используем трансформацию scale для подгонки ширины индикатора, поскольку, как мы разобрали в части 1, трансформации более эффективны, чем изменение значений свойств вроде left или, как в нашем случае, width.

Это также позволяет нам легко написать код, в котором размер задается в процентах: текущее значение sliderX, которое находится между значениями minXOffset и maxXOffset.

Давайте начнем с получения нашего индикатора div.progress-bar после селектора кнопки для перехода к предыдущему элементу (prev):

1
const progressBar = container.querySelector('.progress-bar');

После определения sliderRenderer мы можем добавить рендерер (* аппаратный или программный продукт, выполняющий рендеринг изображения) для progressBar:

1
const progressBarRenderer = css(progressBar);

Теперь давайте добавим функцию для обновления значения scaleX индикатора хода процесса.

Мы воспользуемся функцией объекта calc (* объект с методами для проведения различных вычислений) под названием getProgressFromValue. Она принимает диапазон, который в нашем случае задается значениями minXOffset и maxXOffset, и третье число. Эта функция возвращает информацию о положении, число между 0 и 1, того третьего числа в пределах заданного диапазона.

1
function updateProgressBar(x) {
2
  const progress = calc.getProgressFromValue(maxXOffset, minXOffset, x);
3
  progressBarRenderer.set('scaleX', progress);
4
}

Мы указали диапазон здесь в виде maxXOffset, minXOffset, тогда как интуиция подсказывает, что эти значения должны были бы быть указаны в обратном порядке. Это так, поскольку значениями x и maxXOffset являются отрицательные числа, тогда как значением minXOffset является 0. Формально 0 является большим из двух вышеуказанных чисел (* maxXOffset и minXOffset), однако меньшее значение собственно представляет максимальный отступ. Отрицательные значения, вы поняли?

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

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

На эту:

1
const sliderX = value(0, (x) => {
2
  updateProgressBar(x);
3
  sliderRenderer.set('x', x);
4
});

Теперь каждый раз при обновлении значения sliderX будет обновляться и значение индикатора хода процесса.

Заключение

На этом мы завершили с этой частью! Вы можете скачать самый последний код в этом Pen на Codepen. Мы успешно реализовали возможность прокрутку карусели по горизонтали при помощи колесика прокрутки, пагинации и индикатор хода процесса.

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

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

Тогда и увидимся!

Advertisement
Did you find this post useful?
Want a weekly email summary?
Subscribe below and we’ll send you a weekly email summary of all new Web Design tutorials. Never miss out on learning about the next big thing.
Advertisement
Looking for something to help kick start your next project?
Envato Market has a range of items for sale to help get you started.