حدث لموضع 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 التغييرات بشكل غير متزامن في تقاطع عنصر مستهدَف وإطار عرض المستند أو حاوية رئيسية. في حالتنا، نلاحظ التقاطعات مع الحاوية الرئيسية.

القيمة السحرية هي 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" (مراقب تغيُّر النمط) مفيدة لرصد التغييرات التي تطرأ على الأنماط المحسوبة لأحد العناصر. position: sticky