حدث لموضع CSS:Sticky

الملخّص

إليك سر: قد لا تحتاج إلى أحداث scroll في تطبيقك بعد ذلك. باستخدام IntersectionObserver، أوضح كيفية تنشيط حدث مخصَّص عندما تصبح عناصر position:sticky ثابتة أو عند توقفها عن اللصق. كل ذلك دون استخدام مستمعي التمرير. وهناك أيضًا عرض توضيحي رائع لإثبات ذلك:

مشاهدة العرض التوضيحي | المصدر

التعريف بحدث sticky-change

يتمثل أحد القيود العملية لاستخدام موضع تثبيت CSS في أنه لا يوفر إشارة نظام أساسي لمعرفة الوقت الذي يكون فيه الموقع نشطًا. بمعنى آخر، ليس هناك حدث لمعرفة متى يصبح العنصر لزجًا أو عندما يتوقف عن اللصق.

إليك المثال التالي الذي يثبت <div class="sticky"> 10 بكسل من أعلى حاويتها الرئيسية:

.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 تزيل العنصر من التنسيق عندما يتم إصلاحه.

لذلك بدون أحداث التمرير، فقدنا إمكانية إجراء العمليات الحسابية المتعلقة بالتنسيق في العناوين.

إضافة dumby DOM لتحديد موضع التمرير

وبدلاً من أحداث scroll، سنستخدم IntersectionObserver لتحديد وقت دخول headers إلى وضع التثبيت والخروج منها. ستؤدي إضافة عقدتين (المعروفة أيضًا باسم "الحراس") في كل قسم ثابت، إحداهما في الجزء العلوي والأخرى في الجزء السفلي، كنقاط طريق لمعرفة موضع التمرير. وعند دخول هذه العلامات إلى الحاوية والخروج منها، يتغير مستوى رؤيتها، وينشط "مراقب التقاطع" معاودة الاتصال.

بدون عرض عناصر الحارس
عناصر الحارس المخفية:

نحتاج إلى اثنين من الحراس لتغطية أربع حالات من الانتقال للأعلى وللأسفل:

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

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

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

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

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