ТЛ;ДР
Вот секрет: вам могут не понадобиться события scroll
в вашем следующем приложении. Используя IntersectionObserver
, я показываю, как можно запустить пользовательское событие, когда элементы position:sticky
становятся фиксированными или когда они перестают прилипать. И все это без использования прослушивателей прокрутки. Есть даже потрясающая демонстрация, подтверждающая это:
Представляем событие sticky-change
Одним из практических ограничений использования липкой позиции CSS является то, что она не предоставляет платформе сигнал о том, что свойство активно . Другими словами, не существует события, позволяющего узнать, когда элемент станет липким или когда он перестанет быть липким.
Возьмем следующий пример, который исправляет <div class="sticky">
на 10 пикселей от верха родительского контейнера:
.sticky {
position: sticky;
top: 10px;
}
Было бы неплохо, если бы браузер сообщал, когда элементы достигают этой отметки? Видимо не я один так думаю. Сигнал для position:sticky
может разблокировать ряд вариантов использования :
- Примените тень к баннеру, когда он приклеится.
- Когда пользователь читает ваш контент, записывайте обращения аналитики, чтобы отслеживать их прогресс.
- Когда пользователь прокручивает страницу, обновите плавающий виджет оглавления до текущего раздела.
Принимая во внимание эти варианты использования, мы сформулировали конечную цель: создать событие, которое срабатывает, когда элемент position:sticky
становится фиксированным. Назовем это событием sticky-change
:
document.addEventListener('sticky-change', e => {
const header = e.detail.target; // header became sticky or stopped sticking.
const sticking = e.detail.stuck; // true when header is sticky.
header.classList.toggle('shadow', sticking); // add drop shadow when sticking.
document.querySelector('.who-is-sticking').textContent = header.textContent;
});
Демо использует это событие для заголовка тени, когда она становится фиксированной. Он также обновляет новый заголовок вверху страницы.
Эффекты прокрутки без событий прокрутки?
![Структура страницы.](https://developer.chrome.google.cn/static/docs/css-ui/sticky-headers/image/structure-the-page-de19d5c111bd1.png?authuser=0000&hl=ru)
Давайте разберемся с некоторой терминологией, чтобы я мог ссылаться на эти имена в оставшейся части статьи:
- Контейнер прокрутки — область контента (видимая область просмотра), содержащая список «сообщений в блоге».
- Заголовки — синий заголовок в каждом разделе, который имеет
position:sticky
. - Прикрепленные разделы — каждый раздел контента. Текст, который прокручивается под прикрепленными заголовками.
- «Режим закрепления» — когда к элементу применяется
position:sticky
.
Чтобы узнать, какой заголовок переходит в «прикрепленный режим», нам нужен какой-то способ определения смещения прокрутки контейнера прокрутки . Это дало бы нам возможность вычислить заголовок , который отображается в данный момент. Однако без событий scroll
сделать это довольно сложно :) Другая проблема заключается в том, что position:sticky
удаляет элемент из макета, когда он становится фиксированным.
Таким образом, без событий прокрутки мы потеряли возможность выполнять вычисления, связанные с макетом, для заголовков.
Добавление тупого DOM для определения положения прокрутки
Вместо событий scroll
мы собираемся использовать IntersectionObserver
, чтобы определить, когда заголовки входят в режим закрепления и выходят из него. Добавление двух узлов (так называемых стражей) в каждый прикрепленный раздел , один вверху и один внизу, будет действовать как путевые точки для определения положения прокрутки. Когда эти маркеры входят в контейнер и покидают его, их видимость меняется, и Intersection Observer запускает обратный вызов.
![Без показа сторожевых элементов](https://developer.chrome.google.cn/static/docs/css-ui/sticky-headers/image/without-sentinel-elements-f9529d3483c91.gif?authuser=0000&hl=ru)
Нам нужны два стража, чтобы покрыть четыре случая прокрутки вверх и вниз:
- Прокрутка вниз — заголовок становится липким, когда его верхний страж пересекает верхнюю часть контейнера.
- Прокрутка вниз — заголовок выходит из закрепленного режима, когда достигает нижней части раздела, а его нижний индикатор пересекает верхнюю часть контейнера.
- Прокрутка вверх — заголовок выходит из режима закрепления, когда его верхний индикатор прокручивается обратно в поле зрения сверху.
- Прокрутка вверх — заголовок становится липким, поскольку его нижний индикатор снова появляется в поле зрения сверху.
Полезно просмотреть скринкасты 1–4 в том порядке, в котором они происходят:
CSS
Датчики расположены вверху и внизу каждой секции. .sticky_sentinel--top
находится в верхней части заголовка, а .sticky_sentinel--bottom
— в нижней части раздела:
![Нижний страж достигает своего порога.](https://developer.chrome.google.cn/static/docs/css-ui/sticky-headers/image/bottom-sentinel-reaching-208a13f07bd7c.png?authuser=0000&hl=ru)
:root {
--default-padding: 16px;
--header-height: 80px;
}
.sticky {
position: sticky;
top: 10px; /* adjust sentinel height/positioning based on this position. */
height: var(--header-height);
padding: 0 var(--default-padding);
}
.sticky_sentinel {
position: absolute;
left: 0;
right: 0; /* needs dimensions */
visibility: hidden;
}
.sticky_sentinel--top {
/* Adjust the height and top values based on your on your sticky top position.
e.g. make the height bigger and adjust the top so observeHeaders()'s
IntersectionObserver fires as soon as the bottom of the sentinel crosses the
top of the intersection container. */
height: 40px;
top: -24px;
}
.sticky_sentinel--bottom {
/* Height should match the top of the header when it's at the bottom of the
intersection container. */
height: calc(var(--header-height) + var(--default-padding));
bottom: 0;
}
Настройка наблюдателей пересечений
Наблюдатели пересечений асинхронно наблюдают за изменениями в пересечении целевого элемента и области просмотра документа или родительского контейнера. В нашем случае мы наблюдаем пересечения с родительским контейнером.
Волшебный соус — IntersectionObserver
. Каждый страж получает IntersectionObserver
для наблюдения за видимостью его пересечения внутри контейнера прокрутки . Когда страж прокручивается в видимую область просмотра, мы знаем, что заголовок стал фиксированным или перестал быть липким . Аналогично, когда страж выходит из области просмотра.
Сначала я настроил наблюдателей для датчиков верхнего и нижнего колонтитула:
/**
* Notifies when elements w/ the `sticky` class begin to stick or stop sticking.
* Note: the elements should be children of `container`.
* @param {!Element} container
*/
function observeStickyHeaderChanges(container) {
observeHeaders(container);
observeFooters(container);
}
observeStickyHeaderChanges(document.querySelector('#scroll-container'));
Затем я добавил наблюдателя, который срабатывает, когда элементы .sticky_sentinel--top
проходят через верхнюю часть контейнера прокрутки (в любом направлении). Функция observeHeaders
создает верхние стражи и добавляет их в каждый раздел. Наблюдатель вычисляет пересечение индикатора с верхней частью контейнера и решает, входит ли он в область просмотра или покидает ее. Эта информация определяет, прилипнет ли заголовок раздела или нет.
/**
* Sets up an intersection observer to notify when elements with the class
* `.sticky_sentinel--top` become visible/invisible at the top of the container.
* @param {!Element} container
*/
function observeHeaders(container) {
const observer = new IntersectionObserver((records, observer) => {
for (const record of records) {
const targetInfo = record.boundingClientRect;
const stickyTarget = record.target.parentElement.querySelector('.sticky');
const rootBoundsInfo = record.rootBounds;
// Started sticking.
if (targetInfo.bottom < rootBoundsInfo.top) {
fireEvent(true, stickyTarget);
}
// Stopped sticking.
if (targetInfo.bottom >= rootBoundsInfo.top &&
targetInfo.bottom < rootBoundsInfo.bottom) {
fireEvent(false, stickyTarget);
}
}
}, {threshold: [0], root: container});
// Add the top sentinels to each section and attach an observer.
const sentinels = addSentinels(container, 'sticky_sentinel--top');
sentinels.forEach(el => observer.observe(el));
}
Наблюдатель настроен с threshold: [0]
поэтому его обратный вызов срабатывает, как только дозорный становится видимым.
Процесс аналогичен нижнему дозорному ( .sticky_sentinel--bottom
). Второй наблюдатель создается для срабатывания, когда нижний колонтитул проходит через нижнюю часть контейнера прокрутки . Функция observeFooters
создает контрольные узлы и присоединяет их к каждому разделу. Наблюдатель вычисляет пересечение датчика с нижней частью контейнера и решает, входит он или выходит. Эта информация определяет, прилипнет ли заголовок раздела или нет.
/**
* Sets up an intersection observer to notify when elements with the class
* `.sticky_sentinel--bottom` become visible/invisible at the bottom of the
* container.
* @param {!Element} container
*/
function observeFooters(container) {
const observer = new IntersectionObserver((records, observer) => {
for (const record of records) {
const targetInfo = record.boundingClientRect;
const stickyTarget = record.target.parentElement.querySelector('.sticky');
const rootBoundsInfo = record.rootBounds;
const ratio = record.intersectionRatio;
// Started sticking.
if (targetInfo.bottom > rootBoundsInfo.top && ratio === 1) {
fireEvent(true, stickyTarget);
}
// Stopped sticking.
if (targetInfo.top < rootBoundsInfo.top &&
targetInfo.bottom < rootBoundsInfo.bottom) {
fireEvent(false, stickyTarget);
}
}
}, {threshold: [1], root: container});
// Add the bottom sentinels to each section and attach an observer.
const sentinels = addSentinels(container, 'sticky_sentinel--bottom');
sentinels.forEach(el => observer.observe(el));
}
Наблюдатель настроен с threshold: [1]
поэтому его обратный вызов срабатывает, когда весь узел находится в пределах видимости.
Наконец, есть две мои утилиты для запуска пользовательского события sticky-change
и создания дозорных:
/**
* @param {!Element} container
* @param {string} className
*/
function addSentinels(container, className) {
return Array.from(container.querySelectorAll('.sticky')).map(el => {
const sentinel = document.createElement('div');
sentinel.classList.add('sticky_sentinel', className);
return el.parentElement.appendChild(sentinel);
});
}
/**
* Dispatches the `sticky-event` custom event on the target element.
* @param {boolean} stuck True if `target` is sticky.
* @param {!Element} target Element to fire the event on.
*/
function fireEvent(stuck, target) {
const e = new CustomEvent('sticky-change', {detail: {stuck, target}});
document.dispatchEvent(e);
}
Вот и все!
Финальная демо-версия
Мы создали специальное событие, когда элементы с position:sticky
становятся фиксированными, и добавили эффекты прокрутки без использования событий scroll
.
Заключение
Я часто задавался вопросом, будет ли IntersectionObserver
полезным инструментом для замены некоторых шаблонов пользовательского интерфейса, основанных на событиях scroll
, которые разрабатывались на протяжении многих лет. Оказывается, ответ – и да, и нет. Семантика API IntersectionObserver
затрудняет его использование для чего угодно. Но, как я показал здесь, вы можете использовать его для некоторых интересных техник.
Другой способ обнаружить изменения стиля?
Не совсем. Нам нужен был способ наблюдать за изменениями стиля элемента DOM. К сожалению, в API-интерфейсах веб-платформы нет ничего, что позволяло бы отслеживать изменения стиля.
MutationObserver
был бы логичным первым выбором, но в большинстве случаев он не работает. Например, в демо-версии мы получим обратный вызов, когда к элементу добавляется sticky
класс, но не при изменении вычисленного стиля элемента. Напомним, что sticky
класс уже был объявлен при загрузке страницы.
В будущем расширение Style Mutation Observer для Mutation Observer может оказаться полезным для наблюдения за изменениями в вычисляемых стилях элемента. position: sticky
.