Веб-разработка
November 3, 2022

Бесконечный слайдер на чистом JavaScript — на примере макета Gllacy от HTML Academy

Решил я, значит, доделать все проекты из первого уровня HTML Academy и начал с самого, на мой взгляд, сложного — Глейси. Но я был бы не я, если бы не усложнил себе задачу. Я решил, помимо прочего (попапы, тултипы, модалки), реализовать работу слайдера, а он там, мягко говоря, непростой.

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

Дисклеймер: JS я делал как умею, а на данный момент я никак не умею. :D Различные продвинутые методы программирования, типа ООП, АОП и т. д., с применением классов, объектных методов и сложных абстракций — это мне еще предстоит изучить. Пока — просто эксперимент.

TL;DRПолный код на GitHub

Вёрстка слайдера

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

Если заранее не подумать о последующей реализации, то в плане вёрстки, тут можно по разному подойти, например, сверстать всё одним списком, текст у превью скрывать display: none, а при слайде показывать. Это первое, что пришло мне в голову.

И я так даже попробовал, но почти сразу начались огромные проблемы с позиционированием самой картинки, фона-кружочка — пришлось делать обёртку для картинки, отдельно обёртку для текста, проблемы с позиционированием триггеров (они же не должны «слайдиться»).

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

Если не фиксировать размер текущего слайда, то flex будет всё сплющивать.

Я же решил сделать красиво и всё это дело анимировать, чтобы это нормально смотрелось, а не «прыгало» мгновениями. Ну и что бы это вообще работало.

Моя вторая идея — разделить блок на два структурно независимых блока — текстовый (слева) и с картинками (справа), задать overflow: hidden контейнерам и поместить внутрь по слайдеру.

Grid one love

Это слегка увеличило вёрстку, но сильно упростило её логику — слайдеры занимают просто 100% контейнера и живут в своём мирке, прекрасно убирая переполнение за overflow, а размеры областей заданы в grid.

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

Еще это потенциально усложнило JS — листать теперь надо два слайда, при том, что в одном у нас есть превью на 2 слайда вперёд, а в другом нет.

Реализация слайдера на JavaScript

Кроме модалок при обучении на примере Техномарта и Кекстаграма в тренажёрах я, в общем-то, ничего не делал. В тренажёрах по JS разбирался базис языка и небольшой блок про работу с DOM. Короче говоря, это мой первый такой самостоятельный практический вызов.

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

Вариант отключить disabled кнопку «влево» при текущем первом слайде решал только пол беды. В итоге идея реализации зацикленного слайдера напросилась сама — не будем ничего дизеблить, кликать можно в любую сторону.

Концепт цикличного слайдера

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

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

  1. Так как смещение не мгновенное, то одновременно на экране в моменте видно сразу два одинаковых итема — первый, ещё не исчезнувший current и второй появляющийся с конца.
  2. Метод .querySelectorAll() собирает их не в обычный объект (или словарь (?) объектов, тут пока для меня мутная терминология), а в так называемый NodeList, который можно только читать read-only и нельзя изменять. Об этом я узнал уже в процессе и об этом чуть далее.

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

Параллельно я нашел другой подход, который как раз мне понравился — через работу с самим NodeList, клонирование итемов через .cloneNode() и подстановка их в конец с помощью .appendChild().

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

Примерный алгоритм работы слайдера по краям:

  1. Текущий current слайд находится на краю массива, не важно в начале или в конце.
  2. Нажатие на кнопку в сторону «за» конец массива — на 1-м <- влево или на 4-м (1-м клоне) вправо ->, вызывает функцию замены позиции с 1-го на позицию клона или наоборот.
  3. После замены срабатывает основная функция слайдера с анимацией смещения.

Ну что, погнали.

Собираем элементы

Для начала нам, естественно, нужно собрать элементы с которыми будем работать. Тут должно быть всё просто — используем .querySelector() и .querySelectorAll() для списков и работаем дальше.

const promoSection = document.querySelector(".promo");
const picSliderList = promoSection.querySelector(".slider__list");
let picSlides = picSliderList.querySelectorAll(".slider__item");
...

Но не тут-то было.

В ходе работы над задачей я, возможно преждевременно, но познакомился с понятиями Node и NodeList и тем, что они бывают двух типов — живые (live) и статичные (static). Это заставило на пол пути почти полностью переписать некоторые функции.

Как я уже писал выше, метод .querySelectorAll() собирает элементы не в массив или обычный объект, а в специальный объект NodeList, который имеет свойство read-only, то есть неизменяемый или «статичный».

console.log(picSlides) // выведет список из 3-х объектов
picSliderList.appendChild("клон/объект"); // добавляем 4-й объект
console.log(picSlides) // всё равно выдаст список из 3-х объектов

Для нас это не очень хорошо — мы планируем изменять количество элементов в контейнере и работать далее уже с изменённым списком. Нам нужен «живой» NodeList или его подобие, который будет реагировать на изменения в DOM.

В этом нам может помочь свойство объекта Node — .childNodes, которое возвращает все дочерние Node, то бишь NodeList и он при этом «живой», то есть реагирует на изменения.

const picSliderList = promoSection.querySelector(".slider__list");
let picSlides = picSliderList.childNodes;

Мы, правда, быстро споткнёмся о нюанс — .childNodes собирает еще и какую-то шелуху, которые не являются элементами, но являются Node’ами.

Насколько я понял — это пробелы от переноса строки, с ними можно «хорошо» познакомиться, если начать работать с inline-block кнопками, например. Или нет и это что-то другое, я тут не эксперт, не слушайте меня. В любом случае — нам оно не надо, нам надо только элементы.

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

Насколько я понял — это немного другой вид данных отличный от NodeList, более «массивоподобный» (array-like object написано в MDN), который для наших целей идеально подходит. В итоге вот что получается:

let picSlides = picSliderList.children;

console.log(picSlides) // 3 объекта
picSliderList.appendChild("клон/объект");
console.log(picSlides) // 4 объекта

Хорошо! Не забываем собрать еще кнопки и пагинацию (кнопки-точки в углу) и переходим к функциональной части.

Клонируем слайды

Не смотря на то, что у текстового слайдера нет превью-итемов, они все за overflow, ему всё равно надо сделать клона крайнего итема. Иначе при слайде вбок, будет моментальная замена, например, с 3-го на 1-й (или наоборот), а затем слайд-анимация ко 2-му, из-за чего всё ломается.

Итак, сама функция клонирования:

const addFirstToEnd = (itemsList) => {
  let cloneItem = itemsList[0].cloneNode(true); //клон 1-го итема
  cloneItem.classList.remove("current"); //убираем сласс current
  itemsList[0].parentNode.appendChild(cloneItem); //подставляем
}

Параметр true/false у .cloneNode определяет копировать ли элемент вместе с внутренностями или только пустой элемент. А свойство .parentNode обращается к родительскому node указанного элемента.

Тут очередная проблема, которую я не сразу заметил — для текстового слайдера нам хватит и одного клона, а вот для картинок получается, что нет. Одновременно в поле видимости слайдера с картинками у нас 3 слайда, просто 2 как превью.

Следовательно, для корректной работы нам надо клонировать все видимые слайды, то бишь вообще все. Два последних итема никогда не будут выбраны, но нужны только для отображения при current на 1-м клоне.

Возникает еще одна дилемма — требуется разное количество клонов. Можно забить и сделать одинаковое — текстовых тоже будет 6, просто 2 последних будут совсем мёртвым грузом. Но я решил доработать функцию, что б она могла делать разное количество клонов:

const addClonesToEnd = (clonesNumber, itemsList) => {
  for (i = 0; i < clonesNumber; i++) {
    let cloneItem = itemsList[i].cloneNode(true);
    cloneItem.classList.remove("current");
    itemsList[i].parentNode.appendChild(cloneItem);
  }
}
addClonesToEnd(3, picSlides); // +2 превью
addClonesToEnd(1, textSlides);

Замена позиции

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

Для проверки будем двигать не слайды, а вспомогательный класс .current, обозначающий выбранный слайд. Благо менять классы у элементов мы уже умеем с помощью методов .classList.add/remove. Сейчас вопрос не «Как», а «Куда двигать?».

Я решил подойди с точки зрения привязки элементов к их индексу в коллекции. Несмотря на то, что у нас разная длина коллекций — 4 у текстового и 6 у картиночного, два последних клона в картиночном нас не интересуют, поэтому позиции во всех случаях у обоих слайдеров будут совпадать.

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

let lastSlideIndex = textSlides.length - 1; // 0 - 1 - 2 - [3]
let currentSlideIndex = 0;
let nextSlideIndex = 1;

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

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

buttonNext.addEventListener("click", () => {
  nextSlideIndex = currentSlideIndex + 1; // -1 для buttonPrev
  
  picSlides[currentSlideIndex].classList.remove("current");
  textSlides[currentSlideIndex].classList.remove("current");
  picSlides[nextSlideIndex].classList.add("current");
  textSlides[nextSlideIndex].classList.add("current");
  
  currentSlideIndex = nextSlideIndex; // заменяем значение текущего
}

Визуально ничего не двигается (меняются размеры картинок только), зато двигается класс:

Быстро упираемся в проблему, когда нажимаем на кнопку влево и получаем nextSlideIndex = -1, такого индекса нет, как и индекса 4, который мы поймаем после нажатия вправо на последнем слайде.

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

Для краткости и чтобы много раз не переписывать эти четыре строки с classList.remove/add я вывел их в отдельную функцию switchClasses():

buttonNext.addEventListener("click", () => {
  nextSlideIndex = currentSlideIndex + 1; // = условно [4]
  
  if (nextSlideIndex > lastSlideIndex) { // для buttonPrev (next < 0)
    nextSlideIndex = 0;
    switchClasses();
    currentSlideIndex = nextSlideIndex; // заменяем значение текущего
    nextSlideIndex = currentSlideIndex + 1; // заново считаем next
  }
  
  switchClasses();
  
  currentSlideIndex = nextSlideIndex; // финально заменяем значение
}

Внезапно для меня всё сработало! Выглядит, возможно, немного неказисто, зато работает — пока не трогаем. Переходим к переходам!

Плавно переходим

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

Самый, эм… распространённый вариант из моих поисков на чистом JavaScript (без jQuery) — это использование свойства .style к элементу. Этим способом мы прописываем элементу inline-стили в атрибут.

picSlider.style.transition = "transform 500ms ease-out";
picSlider.style.transform = "translateX(-100px)"; // подвинет влево

Код выше применит в HTML к указанному элементу атрибут style:

И это, в целом, то что нам нужно — подставим изменение стилей в функцию нажатия кнопки, в translateX() подставим нужную величину сдвига для слайдера, а картинкам можно в CSS прописать transition на размеры и непрозрачность и они сами будут плавно переходить от .current и обратно.

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

picSlider.style.transform: `translateX(-${nextSlideIndex * offset}px)`

То есть, условно, [0]-й слайдер — сдвиг на 0 * 150 = 0px, [1]-й — сдвиг на 150px и так далее. На данном этапе я подсмотрел размер итемов в DevTools во вкладке Computed или можно самому посчитать width + padding + border + margin, но об этом подробнее чуть позже.

Получается, по итогу, что нам надо-то добавить несколько строк:

buttonNext.addEventListener("click", () => {
  nextSlideIndex = currentSlideIndex + 1;
  
  if (nextSlideIndex > lastSlideIndex) { /* замена, тоси-боси */ }
  
  picSlider.style.transition = "transform 500ms ease-out";
  textSlider.style.transition = "transform 500ms ease-out";
  picSlider.style.transform = `translateX(-${nextSlideIndex * 141}px)`;
  textSlider.style.transform = `translateX(-${nextSlideIndex * 540}px)`;
  switchClasses();
  
  currentSlideIndex = nextSlideIndex;
}

Вуаля! Всё работает! Нет, правда! Слайды плавно двигаются, листаются. В конце коллекции происходит замена, как мы и хотели, и… идёт обратно?..

В принципе, можно оставить и так. Работает же? Работает. Даже, можно сказать, красиво — анимации итемов, плавно в обе стороны, все ок. Но я всё же решил доделать до изначально задуманного концепта.

Асинхронность JS, переходы и анимация

Первое что мне пришло в голову — надо обнулить transition при замене в if, добавить туда transform к нулю и тогда, по идее, будет сначала производиться мгновенная замена с конца на начало, а пото-о-ом уже плавный переход на итоговый слайд! Но нет.

Во-первых, transition у самих картинок, то бишь изменение размеров и непрозрачности, мы так просто не уберём, нужно обращаться к конкретному элементу — к картинке и еще span'у-кружочку на фоне.

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

Что и случилось, в общем-то, с нашей заменой слайдов — JS выполнил всё, грубо говоря, одновременно. Он посчитал индексы, финальным оказался индекс [1] — к нему он и применяет translateX(), из-за чего слайдер не «зациклился», а переместился в обратном направлении.

Тут я полез разбираться и залез в очень глубокие дебри, в которые, на мой взгляд, пока рановато. Либо учить всё прям до момента понимания, на что я не был готов на данный момент (сидеть месяц над слайдером такое себе), либо искать какое-то другое решение, пусть и на 3-х костылях.

Я честно пытался разобраться для более изящного решения, начал вникать в асинхронность JS, в работу «стека вызовов», в контекст вызова и колбэки, в итоге в промисы и async/await. Но у меня не получилось, как бы я не пытался, я где-то сутки-двое сидел. На этом этапе просто не хватило знаний и опыта. Но я решил сделать по-своему! :D

Итак, не победив асинхронность я решил встать на её сторону — пусть выполняется одновременно, тогда будем мучить сами переходы.

Я нашел несколько примеров подобных слайдеров, большинство из которых были, сука, на jQuery, но именно это мне и помогло. Я решил прям взять и скопировать один из вариантов (правда с 2-мя клонами) в CodePen и проинспектировать в DevTools как они себя ведут.

Внезапно для себя я кое-что обнаружил! Вот как ведёт себя переход слайдера у меня, внимание на translateX():

А вот как ведёт себя переход в примере на jQuery, который я нашел на stackoverflow:

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

Анимация против inline-переходов

Оказалось, что используемый в примере на jQuery метод (?) :animate() это не то же самое, что навешать .style.tranform на элемент. Это именно функция. Значит и в нативном JS должно быть что-то подобное и оно действительно есть!

И тут два пути. Более сложный вариант через создание функции анимации, буквально с нуля, используя requestAnimationFrame(), которая принимает колбэк функцию с расчётом Кривой Безье для timing-функции (которые ease/-in/-out), функцией отрисовки и условием продолжительности. Или…

Или использовать более простой метод .animate() DOM элемента, который принимает массив с кейфреймами, работающие аналогично как в CSS, и параметры анимации, которые тоже совпадают с хорошо знакомыми свойствами CSS animation-duration, animation-timing и т. д.

Я выбрал второй вариант и мне не стыдно. Выглядит все вот так:

picSlider.animate([
  {transform: `translateX(-${currentSlideIndex * 141}px)`}, // от или 0%
  {transform: `translateX(-${nextSlideIndex * 141}px)`} // до или 100%
  ], {
  duration: 500,
  easing: "ease-out",
  fill: "forwards"
});

Вся эта запись эквивалентна созданию @keyframes в CSS и применению её к элементу по классу, но в JS мы можем запрограммировать её вызов ну и подставить интерполяцией значения, что удобно.

Но самое главное — эта анимация работает, как бы, «покадрово», так же как анимация из примера — применяя в короткие промежутки времени разные значения из указанного интервала. Я чёрт знает как это на нормальном языке объяснить, надеюсь вы поняли. :D

А следствие всего этого то, что таким образом анимация обрабатывается последовательно! Теперь если мы запихнём .animate() c нулевым duration в условие для замены, то он сначала заменит, а потом проиграет следующий .animate() с плавным переходом!

buttonNext.addEventListener("click", () => {
  nextSlideIndex = currentSlideIndex + 1;
  
  if (nextSlideIndex > lastSlideIndex) {
    nextSlideIndex = 0;
  
    switchClasses();
  
    picSlider.animate([
      {transform: `translateX(-${currentSlideIndex * 141}px)`},
      {transform: `translateX(-${nextSlideIndex * 141}px)`}
      ], { duration: 0 }
    );
    /* ... textSlider аналогично */

    currentSlideIndex = nextSlideIndex;
    nextSlideIndex = currentSlideIndex + 1;
  }
  
  switchClasses();

  picSlider.animate([
    {transform: `translateX(-${currentSlideIndex * 141}px)`},
    {transform: `translateX(-${nextSlideIndex * 141}px)`}
    ], { duration: 500, easing: "ease-out", fill: "forwards"}
  );  
  /* ... textSlider аналогично */
  
  currentSlideIndex = nextSlideIndex;
}

Смещение работает с правильной заменой, без асинхронной подставы! Но, чёрт возьми, у нас появился еще один неприятный нюанс. Смещение работает идеально, но анимация самих итемов при смене класса .current работает всё равно в асинхронном формате.

Тут я совсем уже отчаялся, вернулся опять к теме async/await, потом попытался сделать через два клона — спереди и сзади коллекции, сделать замену через transitionend, но всё не получалось или приводило в итоге к тому же.

На следующее утро решил вернуться к .animate() и пойти просто в лоб — будем анимировать всё что двигается! А что не двигается — двигать и анимировать. Соберём все анимируемые элементы и пропишем им анимацию отдельно.

Собираем всё для анимации

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

У меня каждый итем это <li>, внутри в текстовом, внезапно — текст и кнопка, а внутри картиночного — картинка <img> и <span>, имеющий роль кружочка.

Я сначала сверстал его через ::before, как и полагается декорации, но с псевдоэлементами туго работает JS, так как псевдоэлемент на то и псевдо- и является элементом стилей, а не вёрстки.

<!-- без div-обёрток -->
<ul class="slider-text__list"> <!-- слайды текста -->
  <li class="slider-text__item current"> ... </li>
  <li class="slider-text__item"> ... </li>
  ...
</ul>
...
<ul class="slider__list"> <!-- слайды картинок -->
  <li class="slider__item current">
    <img src="pic1.png"> <!-- картинка -->
    <span></span> <!-- кружок на фоне -->
  </li>
  <li class="slider__item"> ... </li>
  ...
</ul>

Анимация у меня задумана следующая:

  1. Текстовый .slider-text__item итем меняет размер scale() и непрозрачность opacity, имитируя поведение картиночного итема.
  2. Картиночный .slider__item меняет непрозрачность opacity, а так же по макету у них разные поля padding (для «вылета» кнопок на .current).
  3. Картинка <img> в итеме меняет размеры width и height.
  4. Фоновый кружок <span> меняет размеры width и height.

Сразу, наверное, поясню — я пробовал сделать через scale() на картиночном итеме .slider__item, получалось хреново — размер уменьшается только визуально, при этом занятое место под элемент не уменьшается, нам же надо именно перерасчет ширины итема.

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

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

Но ладно. Сначала попробуем, а потом будем решать. Итого нам надо собрать:

/* элементы текущих слайдов */
let textItem = textSlides[currentSlideIndex]; // текстовый
let picItem = picSlides[currentSlideIndex]; // картиночный
let picItemImg = picItem.children[0]; // сама картинка <img>
let picItemSpan = picItem.children[1]; // <span> кружочек

/* элементы следующих слайдов */
let nextTextItem = textSlides[nextSlideIndex];
let nextPicItem = picSlides[nextSlideIndex];
let nextPicItemImg = nextPicItem.children[0];
let nextPicItemSpan = nextPicItem.children[1];

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

const getItemsByIndex = () => {
  textItem = textSlides[currentSlideIndex];
  picItem = picSlides[currentSlideIndex];
  picItemImg = picItem.children[0];
  picItemSpan = picItem.children[1];
  
  nextTextItem = textSlides[nextSlideIndex];
  nextPicItem = picSlides[nextSlideIndex];
  nextPicItemImg = nextPicItem.children[0];
  nextPicItemSpan = nextPicItem.children[1];
};
buttonNext.addEventListener("click", () => {
  nextSlideIndex = currentSlideIndex + 1;
  
  if (nextSlideIndex > lastSlideIndex) {
    nextSlideIndex = 0;
    
    getItemsByIndex(); // получаем элементы по текущим индексам
    switchClasses();
  
    picSlider.animate(/* 0ms анимация слайдера */);
    textSlider.animate(/* 0ms анимация слайдера */);
    /* ... анимируем замену всего остального */

    currentSlideIndex = nextSlideIndex;
    nextSlideIndex = currentSlideIndex + 1;
  }
  
  getItemsByIndex();
  switchClasses();

  picSlider.animate(/* анимация слайдера */);
  textSlider.animate(/* анимация слайдера */);
  /* ... анимируем плавно всё остальное */

  currentSlideIndex = nextSlideIndex;
}

Получается уже нечто монструозное, даже с комментариями. Итак, что дальше? Каждому элементу теперь нужно применить .animate() со своими параметрами. Параметры можно взять опять во вкладке Computed, давайте попробуем, пойдем по порядку:

textItem.animate([
  {transform: scale(1), opacity: 1},
  {transform: scale(0.5), opacity: 0.5}
  ], {duration: 500, easing: "ease-out", fill: "forwards"}
);
picItem.animate(
  {padding: 0 20px, opacity: 1},
  {padding: 0 5px, opacity: 0.5}
  ], {duration: 500, easing: "ease-out", fill: "forwards"}
)
picItemImg.animate(
  {width: 306px, height: 507px}, ...

Да ну нахрен! Это только три элемента из восьми и только для плавного 500ms перехода без замены. А это еще надо воткнуть в функцию на кнопку! Ужас!

Меняем план — выводим все анимации в отдельную функцию и стараемся максимально оптимизировать процесс.

Получаем Сomputed значения свойств

Мне изначально немного свербило в голове, что приходится что-то калькулировать из DevTools и подставлять это в код. То есть, по сути, я брал значения из JavaScript в браузере и вставлял к себе в JavaScript. Может быть JavаматьегоScript сам умеет находить нужные ему циферки?

И оказывается умеет! Есть несколько способов получить свойства элемента, но мне первым попался, да и больше понравился, вариант использования функции getComputedStyle(), которая принимает элемент и возвращает огромный объект CSSStyleDeclaration со всеми свойствами элемента, коих аж 342.

У этой функции свои нюансы, например, она реагирует на свойство box-sizing и по итогу в width выдаёт значение суммы width и padding если стоит значение border-box. Еще я в процессе работы заметил проблему с height — сюда в сумму уходит каким-то хреном line-height из-за чего нормально посчитать высоту итема с текстом будет трудно, но благо нам она и не нужна оказалась.

Что это значит для нас? У нас есть элементы в разных состояниях, с разными свойствами — мы можем просто собрать с них все стили и применять их как состояния в анимации. На примере текстового итема:

/* собираем стили состояний */
const currentTextItemStyles = getComputedStyle(textItem); //"большой"
const defaultTextItemStyles = getComputedStyle(nextTextItem); //"маленький"

/* делаем массивы кейфреймов */
const textItemIncrease = [defaultTextItemProps, currentTextItemProps];
const textItemDecrease = [currentTextItemProps, defaultTextItemProps];

/* анимируем используя кейфреймы */
textItem.animate(textItemDecrease, {duration: 500, ...}) // уменьшаем
nextTextItem.animate(textItemIncrease, {duration: 500, ...}) // увеличиваем

Отлично! Уже значительно сократили писанину. Но меня на этом моменте смутило, что мы используем в анимации четыре раза два огромных массива по 300+ свойств. А это только один элемент. Нам же по сути нужны 2-3 свойства в каждой. Я решил попробовать избавиться от лишнего.

Отсеиваем лишние свойства

Сам объект CSSStyleDeclaration хранит в себе, в том числе, свойства в виде пар «свойство — значение». Я подумал было бы круто их как-то достать, именно те, что мне нужны. Как достать значения я прекрасно знал — по индексу или по ключу. А можно ли как-то вытянуть именно пару ключ-значение?

Можно, конечно! Используя хитрую функцию .reduce(), которая, как бы, разбирает объект или массив по составляющим, используя колбэк функцию, где прописывается по каким условиям делать разбор.

В поисках я нашёл еще более хитрую конструкцию с применением функции .reduce(), на которую натурально минут 40 сидел и просто пялился, тупо чтобы понять что она вообще делает. А делает она как раз то, что мне надо — возвращает массив пар ключ-значение по заданным ключам.

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

Короче, я её трансформировал для себя в более понятном (надеюсь) виде. Занимает может и не одну строчку, зато переведено с квантово-индусского:

const getElementProps = (element, keys) => {
  elementStyles = getComputedStyle(element);
  return keys.reduce(
    (props, key) => {
      if (key in elementStyles) {
        props[key] = elementStyles[key];
        return props;
      }
    }, {/* initialValue */} // пустой объект для сбора пар
  )};

Итак, разбираемся что происходит:

  1. Функция getElementProps() принимает элемент и массив с ключами искомых свойств вида ["width", "height", ... ].
  2. Элементу находим все стили через getComputedStyle() и записываем в локальную переменную elementStyles.
  3. В возврат return (можно и отдельно) записываем .reduce() функцию, которая пройдется по нашему массиву искомых ключей.
  4. Функция .reduce() принимает два аргумента — анонимную (не названную) колбэк функцию и initialValue — пустой объект, куда будут записываться возвраты return колбэк функции, то бишь пары ключ-значение.
  5. Колбэк принимает два аргумента — аккумулятор props, куда записывается каждый результат return колбэка и обрабатываемый в одной итерации элемент key. По сути, это всё такой хитрый эквивалент for (key of keys).
  6. Внутри колбэк функции в условии проверяется наличие ключа key, например «width», в объекте через оператор in и в случае нахождения записывает значение по ключу в аккумулятор.

В итоге у нас получается вожделенный объект нужных нам свойств! На примере картиночного слайдера — нам нужны размеры width и height для картинки <img> и кружочка <span>, потом opacity для итема и еще margin-right для расчёта смещения. Получается:

const picItemProps = ["width", "height", "opacity", "marginRight"];

const currentPicItemProps = getElementProps(picItem, picItemProps);
const currentPicItemImgProps = getElementProps(picItemImg, picItemProps);
const currentPicItemSpanProps = getElementProps(picItemSpan, picItemProps);
/* аналогично для default состояния */
Пример что в итоге в currentPicItemProps

А для смещения нам нужно только численное значения ширины width и отступа margin, без px, так как мы потом будем подставлять его через интерполяцию в строку. Получаем его из наших пропсов с помощью функции parseInt():

const picSlideOffset = 
  parseInt(defaultPicItemProps.width) // не помещается :D
  + parseInt(defaultPicItemProps.marginRight); // = 101 + 40 = 141

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

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

const instant = {duration: 0, fill: "forwards"}; // для замены
const ease500 = {duration: 500, easing: "ease-out", fill: "forwards"};

Финальная подстановка

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

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

const moveContainer = (offset) => {
  return [
    {transform: `translateX(-${currentSlideIndex * offset}px)`},
    {transform: `translateX(-${nextSlideIndex * offset}px)`}
  ]};

И далее в .animate() как аргумент подставляем эту функцию с нужным оффсетом. Остальные .animate() функции принимают уже константы из нашей базы и работают с ними.

Я также для сокращения писанины решил вывести в отдельную функцию все анимации из кнопки и чтобы функция была универсальной — будем передавать в неё параметры анимации в зависимости от ситуации. Таким образом функция подойдет и в замене и в плавном переходе:

const itemsAnimation = (timing) => {
  textSlider.animate(moveContainer(textSlideOffset), timing);
  picSlider.animate(moveContainer(picSlideOffset), timing);
  
  textItem.animate(textItemDecrease, timing);
  picItem.animate(picItemOpacityOff, timing);
  picItemImg.animate(picItemImgDecrease, timing);
  picItemSpan.animate(picItemSpanDecrease, timing);
  
  nextTextItem.animate(textItemIncrease, timing);
  nextPicItem.animate(picItemOpacityOn, timing);
  nextPicItemImg.animate(picItemImgIncrease, timing);
  nextPicItemSpan.animate(picItemSpanIncrease, timing);
}

А в кнопке у нас остаётся только:

buttonNext.addEventListener("click", () => {
  nextSlideIndex = currentSlideIndex + 1;
  
  if (nextSlideIndex > lastSlideIndex) {
    nextSlideIndex = 0;
    
    getItemsByIndex();
    itemsAnimation(instant);
    switchClasses();
  
    currentSlideIndex = nextSlideIndex;
    nextSlideIndex = currentSlideIndex + 1;
  }
  
  getItemsByIndex();
  itemsAnimation(ease500);
  switchClasses();

  currentSlideIndex = nextSlideIndex;
}

А вот, в общем-то, и всё. Слайдер готов в том виде, в котором я изначально хотел. Я у себя в коде еще немного намудрил с выведением отдельно функций в кнопке swapSlides() и moveSlider() — которые еще чуть укорачивают запись.

Остальные задачи

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

Пагинация

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

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

Для начала навесим на кнопки функции на клик. Кнопок у нас три, но они однотипные — номер кнопки ведёт к номеру слайда. Тут прям напрашивается цикл и его мы и применим.

for (let i = 0; i < buttonsDots.length; i++) {
  buttonsDots[i].addEventListener("click", () => {
    nextSlideIndex = i; // номер кнопки от i = 0 равно индексу слайда
    
    getItemsByIndex();
    itemsAnimation(ease500);
    switchClasses();
    
    currentSlideIndex = nextSlideIndex;
});

Еще надо сразу учесть проблему в случае позиции слайдера на клоне (4-м слайде) и нажатии, например на 3-й слайд. С кодом выше анимация будет влево, что немного неожиданно для цикличного слайдера — мы же на 1-м слайде, а листается «назад».

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

if (currentSlideIndex === lastSlideIndex) { // если точно последний
  nextSlideIndex = 0; // меняем на первый
  
  getItemsByIndex();
  itemsAnimation(instant);
  switchClasses();
  
  nextSlideIndex = i;
} // потом уже слайдим

Далее решим со стилями точек, напомню, выглядят они вот так:

Текущая .current — непрозрачная, а остальные полупрозрачные. Всё привязано к тому же вспомогательному классу, поэтому для «листания» точек немного расширим функцию switchClasses(). При этом надо учесть, опять же, что слайдов на один больше чем точек:

const switchClasses = () => {
  /* ... Меняем классы итемам ... */
  
  if (currentSlideIndex === lastSlideIndex) {
    buttonsDots[0].classList.remove("current");
  } else {
    buttonsDots[currentSlideIndex].classList.remove("current");
  }  
  
  if (nextSlideIndex === lastSlideIndex) {
    buttonsDots[0].classList.add("current");
  } else {
    buttonsDots[nextSlideIndex].classList.add("current");
  }
}

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

В этом случае правда будет небольшой баг — при current позиции слайдера на клоне и нажатии на закрашенную, но всё еще активную первую точку (так как i в цикле будет 0, что отлично от current) будет срабатывать кривая анимация.

В этом случае можно не мудрить и просто дизеблить кнопку по классу. Отключить нажатия по ней можно с помощью свойства pointer-events: none.

.slider-dots__control.current {  pointer-events: none; }

Смена стилей сайта

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

У меня в стилях заранее заготовлены стили для трёх состояний тега <body>:

  1. Без дополнительных классов стиль сайта розовый.
  2. С классом .page-body--blue оформление (фон, кнопочки, акценты) меняются на голубенький.
  3. С классом .page-body--yellow на жёлтый.

Нам осталось только воткнуть смену этих классов на <body>, но с определёнными условиями. Нам нужно что бы оформление менялось одно за другим, то бишь надо как-то условно ограничить в зависимости от индексов.

Долго тут мусолить нечего — берём индексы слайдов и проверку на остатки, чтобы учесть теоретическое переполнение, получаем:

if (nextSlideIndex === 0 || nextSlideIndex % lastSlideIndex === 0) {
  document.body.classList.remove("page-body--blue", "page-body--yellow")
} else if (nextSlideIndex === 1 || nextSlideIndex % lastSlideIndex === 1) {
  document.body.classList.add("page-body--blue");
  document.body.classList.remove("page-body--yellow")
} else if (nextSlideIndex === 2 || nextSlideIndex % lastSlideIndex === 2) {
  document.body.classList.add("page-body--yellow")
  document.body.classList.remove("page-body--blue");
}

Пихаем полученную красоту в ту же функцию switchClasses() и получаем итоговый готовый слайдер. Теперь точно всё!

Ссылки и финал

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

Во время работы я много чего находил, все ссылки со Stackoverflow и MDN я кидать не буду, но ключевые штуки которые мне помогли оставлю:

  • Вопрос на Stackoverflow где написан концепт бесконечного слайдера на jQuery за счет клонирования первого и последнего итема.
  • Вариант слайдера с заменой на transitionend, косвенно помог — натолкнул на идею с индексами и помог разобраться с интерполяцией.
  • Хитрая фича с .reduce() сломавшая мне мозг, но которая помогла мне решить вопрос с фильтрацией свойств.

И еще десятки статей с MDN, спецификаций и других ресурсов.

Telegram — Twitter — Instagram — VK