Бегущая строка за четыре дня
24 Мар 2023
Бегущая строка выглядит почти оскорбительно простой задачей — положить элементы в ряд, сдвигать их влево или вправо и повторять всё сначала. Примерно так я и начал эксперимент с GSAP, ScrollTrigger и Custom Elements.
Через четыре дня в коде уже были клонирование содержимого, три направления движения, адаптивные границы, пауза за пределами экрана и отдельная логика для изменения размеров на мобильных. Самой простой частью всё ещё оставалось само движение.
Сегодня опубликовал marquee-content@1.0.0. Разберу, что вошло в первую стабильную версию, какие решения сработали и где небольшой эксперимент уже успел накопить заметный технический долг.
Минимальная оболочка Custom Element
Я хотел, чтобы для подключения компонента не нужно было добавлять служебные обёртки в разметку. Пользователь описывает содержимое, а библиотека занимается движением:
<marquee-content
data-mc-duration="20"
data-mc-direction="auto"
role="marquee"
>
<ul>
<li>Primary</li>
<li>Secondary</li>
<li>Tertiary</li>
</ul>
</marquee-content>
Компонент — автономный Custom Element:
export class MarqueeContent extends HTMLElement {
constructor() {
super();
this.mm = gsap.matchMedia();
this.tl = gsap.timeline();
// Инициализация параметров и анимации
}
}
customElements.get('marquee-content')
|| customElements.define('marquee-content', MarqueeContent);
Проверка через customElements.get() не даёт заново зарегистрировать элемент при повторном подключении скрипта. Сам компонент остаётся в обычном DOM, без Shadow DOM, поэтому стили страницы можно применять к его содержимому напрямую.
У этого решения есть шероховатость. Текущая версия читает атрибуты и дочерние узлы прямо в конструкторе, хотя требования к Custom Elements предписывают отложить такую работу до connectedCallback(). Демо работает, потому что скрипт регистрирует элемент после разбора разметки. Но при более раннем подключении скрипта инициализация может начаться, пока элемент ещё пустой.
Есть и интеграционный долг — демо загружает GSAP 3.11.5 и ScrollTrigger отдельными CDN-скриптами. Пакет уже объявляет gsap зависимостью, но исходный модуль всё равно ждёт глобальные gsap и ScrollTrigger. Версия стабильная, способ подключения — не вполне.
Откуда берётся бесконечность
Одной копии содержимого недостаточно. Когда она уедет за левую границу, справа появится пустое место. Поэтому компонент сначала вычисляет, сколько блоков нужно для заполнения контейнера:
let requiredQuantity = (
this.clientWidth / this.firstElementChild.clientWidth + 3
).toFixed(0);
for (let i = 1; i < requiredQuantity; i++) {
const item = this.firstElementChild;
const clone = item.cloneNode(true);
item.parentNode.append(clone);
}
Условная запись расчёта:
N = round(W / w + 3)
где W — ширина контейнера, w — ширина исходного блока, а N — итоговое количество блоков вместе с оригиналом.
Например, для контейнера шириной 960px и блока шириной 320px получается:
N = round(960 / 320 + 3) = 6
Три блока закрывают видимую ширину, ещё три дают запас во время циклического сдвига. Формула не ищет минимум, а сознательно создаёт лишние копии.
После клонирования GSAP создаёт одну временную шкалу анимации для всех дочерних элементов:
this.tl.to(this.children, {
duration: this.duration,
x: '-100%',
ease: 'none',
repeat: -1,
});
Каждая копия смещается на собственную ширину. Линейное движение (ease: 'none') и бесконечное повторение (repeat: -1) создают непрерывный цикл, а одинаковые копии скрывают переход.
Направление без второй анимации
Для rtl и ltr не нужны две отдельные анимации. Достаточно менять знак timeScale:
this.tl
.to(this.children, {
duration: this.duration,
x: '-100%',
ease: 'none',
repeat: -1,
})
.timeScale(this.dir === 'ltr' ? -1 : 1)
.totalProgress(0.5);
С положительным timeScale анимация воспроизводится вперёд, с отрицательным — назад. totalProgress(0.5) помещает позицию воспроизведения в середину условной общей длительности бесконечно повторяющейся анимации. Это нужно, чтобы обратное проигрывание не остановилось сразу на абсолютном начале.
Третье значение, auto, связывает направление с прокруткой страницы. Обработчик сравнивает текущий pageYOffset с предыдущим и плавно переводит timeScale в 1 или -1:
const orientation = window.pageYOffset > currentScroll ? 1 : -1;
if (orientation !== scrollDirection) {
gsap.to(this.tl, {
timeScale: orientation,
overwrite: true,
});
}
ScrollTrigger решает другую задачу: ставит анимацию на паузу, когда компонент покидает область просмотра (viewport), и возобновляет при возвращении. Невидимая анимация продолжала бы расходовать ресурсы без пользы.
Адаптивность через gsap.matchMedia()
Компонент поддерживает data-mc-min и data-mc-max по отдельности. Каждый из них превращается в медиазапрос, внутри которого создаются клоны и анимация. Если указать оба, min перезапишет запрос для max, поэтому полноценного диапазона из двух границ пока нет.
Для этого используется gsap.matchMedia() из GSAP 3.11. Метод запускает переданную функцию, когда условия медиазапроса выполнены. Когда они перестают выполняться, он откатывает созданные GSAP-анимации и вызывает функцию очистки:
this.mm.add(this.breakpoint, () => {
// Создание клонов и анимации
return () => {
removingClones();
};
});
Это оказалось удобнее отдельного набора matchMedia().addEventListener() и ручного согласования состояния. При выходе из запроса нужно убрать клоны и встроенные стили, иначе отключённый компонент продолжит влиять на раскладку страницы.
GSAP автоматически откатывает созданные внутри функции анимации и ScrollTrigger, а возвращённая функция удаляет клоны. Нативные обработчики scroll, resize и change не снимаются, поэтому очистка жизненного цикла остаётся неполной.
Неприятные сюрпризы
Первый неприятный сюрприз пришёл от iOS. Изменение видимой области браузера во время прокрутки генерировало resize. Обработчик безусловно пересобирал анимацию и заново считал клоны, даже когда ширина не менялась. Повторная инициализация во время прокрутки могла нарушить непрерывность ленты.
Начал с пары экспериментов, уверенный, что быстро решу проблему. Проверял несколько подходов:
- Сравнивал новую ширину окна с предыдущей;
- Пробовал отделять мобильные устройства через
userAgent; - Слушал изменение ориентации;
- Менял задержку debounce;
- Полностью останавливал анимацию перед повторным клонированием.
В текущей версии обработчики подключаются через два медиазапроса:
this.mm.add('(any-pointer: coarse)', () => {
const portrait = window.matchMedia('(orientation: portrait)');
portrait.addEventListener('change', (event) => {
if (!event.matches) {
resetAmin();
}
});
});
this.mm.add('(any-pointer: fine)', () => {
window.addEventListener(
'resize',
this.debounce(resetAmin, 250),
);
});
(any-pointer: coarse) включает обработку смены ориентации, а (any-pointer: fine) — обычный resize с задержкой 250ms. Запросы не взаимоисключающие — на гибридном устройстве могут сработать оба.
Ветвь coarse тоже получилась узкой: она пересобирает компонент только при выходе из портретной ориентации. В этой ветви пересборка не привязана к resize, который возникает при движении браузерной панели.
API версии 1.0
В первый стабильный API вошли пять атрибутов:
data-mc-duration— длительность одного цикла, то есть сдвига на ширину блока, в секундах — по умолчанию20;data-mc-direction—rtl,ltrилиauto;data-mc-skew— наклон по оси Y;data-mc-min— минимальная ширина для запуска;data-mc-max— максимальная ширина для запуска.
data-mc-min и data-mc-max работают как альтернативы, а не как совместный диапазон.
За четыре дня у компонента успело появиться больше обязанностей, чем предполагала исходная идея. Само бесконечное движение занимает несколько строк, а больше всего работы потребовали геометрия, изменение размеров и жизненный цикл Custom Element.