یک رویداد برای موقعیت CSS: چسبنده

TL; DR

در اینجا یک راز وجود دارد: ممکن است نیازی به رویدادهای scroll در برنامه بعدی خود نداشته باشید. با استفاده از IntersectionObserver ، نشان می‌دهم که چگونه می‌توانید یک رویداد سفارشی را هنگامی که عناصر position:sticky ثابت می‌شوند یا زمانی که دیگر نمی‌چسبند، اجرا کنید. همه بدون استفاده از اسکرول شنوندگان. حتی یک نسخه نمایشی عالی برای اثبات آن وجود دارد:

نمایش دمو | منبع

معرفی رویداد sticky-change

یکی از محدودیت های عملی استفاده از موقعیت چسبنده CSS این است که سیگنال پلتفرمی برای اطلاع از زمان فعال بودن ویژگی ارائه نمی دهد . به عبارت دیگر، هیچ رویدادی وجود ندارد که بدانیم چه زمانی یک عنصر چسبنده می شود یا چه زمانی دیگر چسبناک نیست.

مثال زیر را در نظر بگیرید که یک <div class="sticky"> 10px را از بالای کانتینر اصلی خود رفع می کند:

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

آیا خوب نیست اگر مرورگر بگوید چه زمانی عناصر به آن علامت برخورد می کنند؟ ظاهرا من تنها کسی نیستم که اینطور فکر می کنم. یک سیگنال برای position:sticky می تواند تعدادی از موارد استفاده را باز کند:

  1. در حالی که یک بنر می چسبد، یک سایه ی قطره ای اعمال کنید.
  2. همانطور که کاربر محتوای شما را مطالعه می کند، بازدیدهای تجزیه و تحلیل را ثبت کنید تا از پیشرفت آنها مطلع شوید.
  3. هنگامی که کاربر صفحه را پیمایش می کند، ویجت TOC شناور را به بخش فعلی به روز کنید.

با در نظر گرفتن این موارد استفاده، ما یک هدف نهایی ایجاد کرده‌ایم: ایجاد رویدادی که وقتی یک عنصر 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 دریافت می کند تا دید تقاطع خود را در ظرف اسکرول مشاهده کند. هنگامی که یک نگهبان به نمای مرئی می رود، می دانیم که یک هدر ثابت می شود یا دیگر چسبناک نیست . به همین ترتیب، هنگامی که یک نگهبان از viewport خارج می شود.

ابتدا، من ناظران را برای نگهبان هدر و پاورقی تنظیم کردم:

/**
 * 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 ابزار مفیدی برای جایگزینی برخی از الگوهای UI مبتنی بر رویداد scroll است که در طول سال ها توسعه یافته اند. معلوم شد که جواب بله و خیر است. معناشناسی IntersectionObserver API استفاده از آن را برای همه چیز سخت می کند. اما همانطور که در اینجا نشان دادم، می توانید از آن برای چند تکنیک جالب استفاده کنید.

راه دیگری برای تشخیص تغییرات استایل؟

نه واقعا. چیزی که ما نیاز داشتیم راهی برای مشاهده تغییرات سبک در یک عنصر DOM بود. متأسفانه، هیچ چیزی در APIهای پلتفرم وب وجود ندارد که به شما امکان تماشای تغییرات سبک را بدهد.

MutationObserver اولین انتخاب منطقی خواهد بود، اما برای اکثر موارد کار نمی کند. به عنوان مثال، در نسخه ی نمایشی، زمانی که کلاس sticky به یک عنصر اضافه می شود، ما یک callback دریافت می کنیم، اما زمانی که استایل محاسبه شده آن عنصر تغییر می کند، خیر. به یاد بیاورید که کلاس sticky قبلاً در بارگذاری صفحه اعلام شده بود.

در آینده، پسوند " Style Mutation Observer " به Mutation Observers ممکن است برای مشاهده تغییرات در سبک های محاسبه شده یک عنصر مفید باشد. position: sticky