حدث لموضع CSS:Sticky

TL;DR

إليك سرّ: قد لا تحتاج إلى أحداث 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 بالترتيب التالي:

تُطلق Intersection Observers طلبات إعادة الاتصال عندما تدخل أدوات التحكّم في حاوية التنقّل أو تغادرها.

خدمة مقارنة الأسعار

يتم وضع نقاط المراقبة في أعلى وأسفل كل قسم. يظهر .sticky_sentinel--top في أعلى العنوان بينما يظهر .sticky_sentinel--bottom في أسفل القسم:

وصول أداة التحقّق السفلية إلى الحدّ الأدنى
موضع عنصرَي Sentinel العلوي والسفلي
: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;
}

إعداد Intersection Observers

ترصد Intersection Observers التغييرات بشكل غير متزامن في تقاطع عنصر مستهدَف وإطار عرض المستند أو حاوية رئيسية. في حالتنا، نلاحظ تقاطعات مع حاوية رئيسية.

القيمة السحرية هي 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 التي تم تطويرها على مرّ السنين. تبيّن أنّ الإجابة هي نعم ولا. إنّ الدلالات لواجهة برمجة التطبيقات IntersectionObserver تجعل من الصعب استخدامها لأي شيء. ولكن كما هو موضّح في المثال أدناه، يمكنك استخدامها لتنفيذ بعض الأساليب المثيرة للاهتمام.

هل هناك طريقة أخرى لرصد تغييرات الأنماط؟

ليس فعلاً. ما احتجنا إليه هو طريقة لرصد تغييرات الأسلوب في عنصر DOM. لا تتيح لك واجهات برمجة التطبيقات لمنصّة الويب مراقبة تغييرات التصميم.

قد يكون MutationObserver هو الخيار الأول المنطقي، ولكنّه لا يناسب معظم الحالات. على سبيل المثال، في العرض التجريبي، سنتلقّى طلب استدعاء عند إضافة الفئة sticky إلى عنصر، ولكن ليس عند تغيير النمط المحسوب للعنصر. تذكَّر أنّه سبق أن تمّ الإعلان عن فئة sticky عند تحميل الصفحة.

في المستقبل، قد تكون إضافة "Style Mutation Observer" إلى Mutation Observers مفيدة لرصد التغييرات في أنماط العنصر المحسوبة. position: sticky