Бегущая строка за четыре дня
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);
Положительный масштаб времени проигрывает анимацию вперёд, отрицательный — назад. totalProgress(0.5) помещает позицию воспроизведения в середину условной общей длительности бесконечно повторяющейся анимации, чтобы отрицательный timeScale не остановился сразу на абсолютном начале.
Третье значение, 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 тоже получилась узкой: она пересобирает компонент только при выходе из портретной ориентации. Но это устраняет конкретный сбой, не заставляя тяжёлую пересборку срабатывать вслед за движением браузерной панели на сенсорном устройстве.
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.