Событие для позиции CSS:sticky

ТЛ;ДР

Вот секрет: вам могут не понадобиться события scroll в вашем следующем приложении. Используя IntersectionObserver , я показываю, как можно запустить пользовательское событие, когда элементы position:sticky становятся фиксированными или когда они перестают прилипать. И все это без использования прослушивателей прокрутки. Есть даже потрясающая демонстрация, подтверждающая это:

Посмотреть демо | Источник

Представляем событие sticky-change

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

Возьмем следующий пример, который исправляет <div class="sticky"> на 10 пикселей от верха родительского контейнера:

.sticky {
  position: sticky;
  top: 10px;
}

Было бы неплохо, если бы браузер сообщал, когда элементы достигают этой отметки? Видимо не я один так думаю. Сигнал для position:sticky может разблокировать ряд вариантов использования :

  1. Примените тень к баннеру, когда он приклеится.
  2. Когда пользователь читает ваш контент, записывайте обращения аналитики, чтобы отслеживать их прогресс.
  3. Когда пользователь прокручивает страницу, обновите плавающий виджет оглавления до текущего раздела.

Принимая во внимание эти варианты использования, мы сформулировали конечную цель: создать событие, которое срабатывает, когда элемент 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;
});

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

В демо-версии эффекты применяются без событий прокрутки.

Эффекты прокрутки без событий прокрутки?

Структура страницы.
Структура страницы.

Давайте разберемся с терминологией, чтобы я мог ссылаться на эти имена в оставшейся части статьи:

  1. Контейнер прокрутки — область контента (видимая область просмотра), содержащая список «сообщений в блоге».
  2. Заголовки — синий заголовок в каждом разделе, который имеет position:sticky .
  3. Прикрепленные разделы — каждый раздел контента. Текст, который прокручивается под прикрепленными заголовками.
  4. «Режим закрепления» — когда к элементу применяется position:sticky .

Чтобы узнать, какой заголовок переходит в «прикрепленный режим», нам нужен какой-то способ определения смещения прокрутки контейнера прокрутки . Это даст нам возможность вычислить заголовок , который отображается в данный момент. Однако без событий scroll сделать это довольно сложно :) Другая проблема заключается в том, что position:sticky удаляет элемент из макета, когда он становится фиксированным.

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

Добавление тупого DOM для определения положения прокрутки

Вместо событий scroll мы собираемся использовать IntersectionObserver , чтобы определить, когда заголовки входят в режим закрепления и выходят из него. Добавление двух узлов (так называемых стражей) в каждый прикрепленный раздел , один вверху и один внизу, будет действовать как путевые точки для определения положения прокрутки. Когда эти маркеры входят в контейнер и покидают его, их видимость меняется, и Intersection Observer запускает обратный вызов.

Без показа сторожевых элементов
Скрытые дозорные элементы.

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

  1. Прокрутка вниззаголовок становится липким, когда его верхний страж пересекает верхнюю часть контейнера.
  2. Прокрутка вниззаголовок выходит из режима закрепления, когда достигает нижней части раздела, а его нижний индикатор пересекает верхнюю часть контейнера.
  3. Прокрутка вверхзаголовок выходит из режима закрепления, когда его верхний индикатор прокручивается обратно в поле зрения сверху.
  4. Прокрутка вверхзаголовок становится липким, поскольку его нижний индикатор снова появляется в поле зрения сверху.

Полезно просмотреть скринкасты 1–4 в том порядке, в котором они происходят:

Наблюдатели пересечений запускают обратные вызовы, когда стражи входят в контейнер прокрутки или покидают его.

CSS

Датчики расположены вверху и внизу каждой секции. .sticky_sentinel--top находится в верхней части заголовка, а .sticky_sentinel--bottom — в нижней части раздела:

Нижний часовой достигает своего порога.
Положение верхних и нижних сигнальных элементов.
: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 .