От автора: в предыдущем посте я рассказал вам, как создать адаптивный вертикальный таймлайн с нуля. Сегодня я покажу вам процесс создания горизонтального таймлайна. Как обычно, чтобы понять, что мы будем создавать, взгляните на демо ниже (см. большую версию).
У нас много работы, так начнем же! Разметка идентична разметке из вертикального таймлайна, только есть три отличия:
вместо ненумерованного списка мы используем нумерованный, так как это семантически правильнее;
есть дополнительный пустой элемент списка (последний), о котором мы поговорим ниже;
есть дополнительный элемент (.arrows), отвечающий за навигацию по таймлайну.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
<section class="timeline"> <ol> <li> <div> <time>1934</time> Some content here </div> </li> <!-- more list items here --> <li></li> </ol> <div class="arrows"> <button class="arrow arrow__prev disabled" disabled> <img src="arrow_prev.svg" alt="prev timeline arrow"> </button> <button class="arrow arrow__next"> <img src="arrow_next.svg" alt="next timeline arrow"> </button> </div> </section> |
Изначально таймлайн выглядит следующим образом:
Добавляем первичные стили
Для упрощения я пропущу стили для шрифтов, цвета и т.д. и перейду к структурным CSS-правилам:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
.timeline { white-space: nowrap; overflow-x: hidden; } .timeline ol { font-size: 0; width: 100vw; padding: 250px 0; transition: all 1s; } .timeline ol li { position: relative; display: inline-block; list-style-type: none; width: 160px; height: 3px; background: #fff; } .timeline ol li:last-child { width: 280px; } .timeline ol li:not(:first-child) { margin-left: 14px; } .timeline ol li:not(:last-child)::after { content: ''; position: absolute; top: 50%; left: calc(100% + 1px); bottom: 0; width: 12px; height: 12px; transform: translateY(-50%); border-radius: 50%; background: #F45B69; } |
Вы заметите две самые важные вещи:
У списка заданы большие верхний и нижний padding’и. Зачем это нужно, мы объясним в следующей секции.
В демо ниже вы заметите, что пока что нам не видны все элементы списка, так как у списка есть свойство width: 100vw, а у его родителя overflow-x: hidden. Последнее свойство «маскирует» элементы списка. Чуть позже мы сможем перемещаться по элементам списка с помощью навигации.
На данный момент таймлайн выглядит следующим образом (без контента):
Стили элементов таймлайна
Теперь необходимо стилизовать теги div (мы будем называть их элементы таймлайна), которые входят в элементы списка вместе с псевдоэлементами ::before.
Чтобы различать стили для четных и нечетных элементов таймлайна, мы будем использовать псевдоклассы :nth-child(odd) и :nth-child(even).
Общие стили элементов таймлайна:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
.timeline ol li div { position: absolute; left: calc(100% + 7px); width: 280px; padding: 15px; font-size: 1rem; white-space: normal; color: black; background: white; } .timeline ol li div::before { content: ''; position: absolute; top: 100%; left: 0; width: 0; height: 0; border-style: solid; } |
Стили для нечетных элементов:
1 2 3 4 5 6 7 8 9 10 |
.timeline ol li:nth-child(odd) div { top: -16px; transform: translateY(-100%); } .timeline ol li:nth-child(odd) div::before { top: 100%; border-width: 8px 8px 0 0; border-color: white transparent transparent transparent; } |
И для четных элементов:
1 2 3 4 5 6 7 8 9 |
.timeline ol li:nth-child(even) div { top: calc(100% + 16px); } .timeline ol li:nth-child(even) div::before { top: -8px; border-width: 8px 0 0 8px; border-color: transparent transparent transparent white; } |
Теперь таймлайн с контентом выглядит так:
Возможно, вы заметили, что элементы таймлайна абсолютно позиционированы. Это значит, что они удалены из нормального потока в документе. Нам понадобилось задать большие значения верхнего и нижнего padding’а для списка, чтобы быть уверенными, что виден весь таймлайн. Если убрать padding, таймлайн будет обрезаться:
Стили навигации таймлайна
Теперь нужно стилизовать кнопки навигации. Не забывайте, что по умолчанию мы отключаем кнопку назад и присваиваем ей класс disabled.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
.timeline .arrows { display: flex; justify-content: center; margin-bottom: 20px; } .timeline .arrows .arrow__prev { margin-right: 20px; } .timeline .disabled { opacity: .5; } .timeline .arrows img { width: 45px; height: 45px; } |
И наш таймлайн стал:
Добавляем интерактивность
Базовая структура таймлайна готова. Давайте сделаем ее интерактивной!
Переменные
Сперва мы зададим ряд переменных, которые нам потом пригодятся.
1 2 3 4 5 6 7 8 9 |
const timeline = document.querySelector(".timeline ol"), elH = document.querySelectorAll(".timeline li > div"), arrows = document.querySelectorAll(".timeline .arrows .arrow"), arrowPrev = document.querySelector(".timeline .arrows .arrow__prev"), arrowNext = document.querySelector(".timeline .arrows .arrow__next"), firstItem = document.querySelector(".timeline li:first-child"), lastItem = document.querySelector(".timeline li:last-child"), xScrolling = 280, disabledClass = "disabled"; |
Инициализация объектов
Когда все элементы на странице готовы, вызывается функция init.
1 |
window.addEventListener("load", init); |
Эта функция вызывает 4 другие функции:
1 2 3 4 5 6 |
function init() { setEqualHeights(elH); animateTl(xScrolling, arrows, timeline); setSwipeFn(timeline, arrowPrev, arrowNext); setKeyboardFn(arrowPrev, arrowNext); } |
Чуть позже мы увидим, что каждая функция выполняет определенную задачу.
Элементы таймлайна с одинаковой высотой
Если вернуться к предыдущему демо, то можно заметить, что у элементов таймлайна разная высота. Это никак не влияет не главный функционал таймлайна, но, возможно, вы захотите, чтобы все элементы имели одинаковую высоту. Фиксированную высоту можно задать через CSS (простой способ), или же можно устанавливать динамическую высоту, равную высоте самого высокого элемента через JS.
Второй вариант более гибкий и стабильный. Функция, выполняющая описанные выше действия:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
function setEqualHeights(el) { let counter = 0; for (let i = 0; i < el.length; i++) { const singleHeight = el[i].offsetHeight; if (counter < singleHeight) { counter = singleHeight; } } for (let i = 0; i < el.length; i++) { el[i].style.height = `${counter}px`; } } |
Функция вытягивает высоту самого высокого элемента в таймлайне и задает ее по умолчанию для всех элементов.
Анимируем таймлайн
Переходим к анимации таймлайна. Мы создали функцию, которая будет пошагово анимировать таймлайн.
Первым делом регистрируется обработчик события клика по кнопкам:
1 2 3 4 5 6 7 |
function animateTl(scrolling, el, tl) { for (let i = 0; i < el.length; i++) { el[i].addEventListener("click", function() { // код }); } } |
Каждый раз при клике мы проверяем состояние кнопок навигации на disabled, и если они активны, мы отключаем их. Так мы точно будем знать, что до конца анимации кнопки больше не будут нажиматься.
Обработчик клика состоит из этих строк:
1 2 3 4 5 6 7 |
if (!arrowPrev.disabled) { arrowPrev.disabled = true; } if (!arrowNext.disabled) { arrowNext.disabled = true; } |
Далее мы выполняем следующие шаги:
Мы проверяем, был ли это первый клик по кнопке. Не забывайте, что кнопка назад по умолчанию отключена, т.е. изначально можно кликнуть только по кнопке вперед.
Если кнопка нажата первый раз, мы двигаем таймлайн на 280px вправо с помощью свойства transform. Сдвиг задается в переменной xScrolling.
Если же на кнопку уже нажимали, мы получаем текущее значение transform и добавляем к нему или удаляем из него сдвиг (280px). То есть если кликнуть на кнопку назад, значение transform уменьшится, и таймлайн сдвинется слева направо. Если кликнуть на кнопку вперед, значение transform увеличится, и таймлайн сдвинется справа налево.
Код для этих шагов:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
let counter = 0; for (let i = 0; i < el.length; i++) { el[i].addEventListener("click", function() { // предыдущий код const sign = (this.classList.contains("arrow__prev")) ? "" : "-"; if (counter === 0) { tl.style.transform = `translateX(-${scrolling}px)`; } else { const tlStyle = getComputedStyle(tl); // если необходимо, добавьте еще префиксов браузеров const tlTransform = tlStyle.getPropertyValue("-webkit-transform") || tlStyle.getPropertyValue("transform"); const values = parseInt(tlTransform.split(",")[4]) + parseInt(`${sign}${scrolling}`); tl.style.transform = `translateX(${values}px)`; } counter++; }); } |
Замечательно! Мы определили анимацию таймлайна. Теперь необходимо понять, когда анимация должна останавливаться. Наш подход:
Когда первый элемент таймлайна полностью становится видимым, это значит, что мы достигли начала таймлайна, поэтому мы отключаем кнопку назад. Также проверяем, чтобы кнопка вперед была активной.
Когда последний элемент таймлайна полностью становится видимым, это значит, что мы достигли конца таймлайна, поэтому мы отключаем кнопку вперед. Также проверяем, чтобы кнопка назад была активной.
Не забывайте, что последний элемент пустой, и его ширина равна ширине элементов таймлайна (т.е. 280px). Мы задали это значение (или больше), чтобы убедиться, что последний элемент попал в поле зрения перед отключением кнопки вперед.
Чтобы определить, попал ли целевой элемент полностью во вьюпорт или нет, мы возьмем код для вертикального таймлайна. Код взят из ветки Stack Overflow:
1 2 3 4 5 6 7 8 9 |
function isElementInViewport(el) { const rect = el.getBoundingClientRect(); return ( rect.top >= 0 && rect.left >= 0 && rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && rect.right <= (window.innerWidth || document.documentElement.clientWidth) ); } |
Вне функции сверху мы задаем еще один хелпер:
1 2 3 4 5 6 7 8 9 10 |
function setBtnState(el, flag = true) { if (flag) { el.classList.add(disabledClass); } else { if (el.classList.contains(disabledClass)) { el.classList.remove(disabledClass); } el.disabled = false; } } |
Эта функция добавляет и удаляет класс disabled на элемент на основе значения параметра flag. Также функция умеет менять состояние disabled для этого элемента.
Учитывая все вышесказанное, ниже представлен код проверки для остановки анимации:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
for (let i = 0; i < el.length; i++) { el[i].addEventListener("click", function() { // другой код // код остановки анимации setTimeout(() => { isElementInViewport(firstItem) ? setBtnState(arrowPrev) : setBtnState(arrowPrev, false); isElementInViewport(lastItem) ? setBtnState(arrowNext) : setBtnState(arrowNext, false); }, 1100); // другой код }); } |
Заметили, что перед выполнением этого кода стоит задержка в 1.1 секунды? Зачем так делать? Если вернуться в CSS, там есть это правило:
1 2 3 |
.timeline ol { transition: all 1s; } |
Анимация таймлайна выполняется за 1 секунду. Далее мы ждем 100 миллисекунд и выполняем проверки. Таймлайн с анимацией:
Добавляем поддержку свайпов
Пока что таймлайн никак не реагирует на события касаний. Хорошо бы добавить этот функционал. Можно написать свой JS-способ, а можно взять готовую библиотеку (Hammer.js или TouchSwipe.js).
Не будем усложнять и для простоты возьмем Hammer.js. Первым делом добавляем библиотеку в Pen:
Затем объявляем функцию:
1 2 3 4 5 |
function setSwipeFn(tl, prev, next) { const hammer = new Hammer(tl); hammer.on("swipeleft", () => next.click()); hammer.on("swiperight", () => prev.click()); } |
В функции сверху мы:
создаем объект Hammer;
регистрируем обработчики для событий swipeleft и swiperight;
когда мы делаем свайп влево, мы вызываем клик по кнопке вперед, и таймлайн сдвигается справа налево;
когда мы делаем свайп вправо, мы вызываем клик по кнопке назад, и таймлайн сдвигается слева направо.
Таймлайн с поддержкой свайпов:
Добавляем навигацию по клавиатуре
Давайте улучшим UX и добавим поддержку навигации по клавиатуре. Наши цели:
При нажатии кнопок влево и вправо документ должен скролиться к верхней позиции таймлайна (если другая секция страницы сейчас видима). Так весь таймлайн будет виден.
При нажатии стрелки влево таймлайн должен анимироваться слева направо.
При нажатии стрелки вправо таймлайн должен анимироваться справа налево.
Соответствующая функция:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
function setKeyboardFn(prev, next) { document.addEventListener("keydown", (e) => { if ((e.which === 37) || (e.which === 39)) { const timelineOfTop = timeline.offsetTop; const y = window.pageYOffset; if (timelineOfTop !== y) { window.scrollTo(0, timelineOfTop); } if (e.which === 37) { prev.click(); } else if (e.which === 39) { next.click(); } } }); } |
Таймлайн с поддержкой клавиатуры:
Добавляем адаптивность
Почти закончили! Последний, но не менее важный этап – давайте сделаем таймлайн адаптивным. Когда вьюпорт меньше 600px, макет должен переключаться на такой стек:
Так как мы используем подход desktop-first, ниже представлены CSS-правила, которые необходимо переписать:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 |
@media screen and (max-width: 599px) { .timeline ol, .timeline ol li { width: auto; } .timeline ol { padding: 0; transform: none !important; } .timeline ol li { display: block; height: auto; background: transparent; } .timeline ol li:first-child { margin-top: 25px; } .timeline ol li:not(:first-child) { margin-left: auto; } .timeline ol li div { width: 94%; height: auto !important; margin: 0 auto 25px; } .timeline ol li:nth-child div { position: static; } .timeline ol li:nth-child(odd) div { transform: none; } .timeline ol li:nth-child(odd) div::before, .timeline ol li:nth-child(even) div::before { left: 50%; top: 100%; transform: translateX(-50%); border: none; border-left: 1px solid white; height: 25px; } .timeline ol li:last-child, .timeline ol li:nth-last-child(2) div::before, .timeline ol li:not(:last-child)::after, .timeline .arrows { display: none; } } |
Заметка: в двух правилах в коде сверху нам пришлось использовать !important, чтобы переписать инлайновые стили, примененные через JS.
Финальный таймлайн:
Поддержка в браузерах
Демо хорошо работает во всех последних версиях браузеров и устройств. Также вы могли заметить, что мы компилировали наш ES6-код в ES5 с помощью Babel.
Единственная маленькая проблема, с которой я столкнулся при тестировании, заключалась в изменении текста при анимировании. Я испробовал разные подходы со Stack Overflow, но не смог найти простого решения для всех операционных систем и браузеров. Так что знайте, что во время анимации могут возникать небольшие проблемы с рендерингом текста.
Заключение
В этом довольно объемном уроке мы начали с простого нумерованного списка и закончили адаптивным горизонтальным таймлайном. Могу с уверенностью сказать, что мы изучили много чего интересного. Надеюсь, вам понравилась наша работа, и вы почерпнули из нее что-то новое.
Автор: George Martsoukos
Источник: //webdesign.tutsplus.com/
Редакция: Команда webformyself.