אירוע עבור מיקום CSS:דביק

אמ;לק

זהו סוד: יכול להיות שלא יהיה צורך באירועי scroll באפליקציה הבאה. באמצעות IntersectionObserver, כאן מוסבר איך אפשר להפעיל אירוע בהתאמה אישית כשרכיבי position:sticky מתוקנים או כשהם מפסיקים להדביק. כל זה בלי צורך במאזינים גלילה. יש אפילו הדגמה מדהימה שמוכיחה את זה:

לצפייה בהדגמה | מקור

היכרות עם האירוע sticky-change

אחת המגבלות המעשיות לשימוש במיקום דביק של CSS היא שאין אות פלטפורמה שמאפשרת לדעת מתי הנכס פעיל. במילים אחרות, אין אירוע שאפשר לדעת מתי רכיב הופך לדביק או מתי הוא מפסיק להיות במיקום קבוע.

ניקח לדוגמה את הדוגמה הבאה, שמתקנים 10px של <div class="sticky"> בחלק העליון של קונטיינר ההורה:

.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 מוחל על הרכיב.

כדי לדעת איזה header נכנס ל-'sticky mode', אנחנו צריכים דרך לקבוע את היסט הגלילה של מאגר הגלילה. כך נוכל לחשב את הכותרת שמופיעה כרגע. אבל זה די מסובך בלי אירועי scroll :) הבעיה הנוספת היא ש-position:sticky מסיר את הרכיב מהפריסה כשהבעיה תיפתר.

לכן בלי אירועי גלילה, איבדנו את היכולת לבצע חישובים שקשורים לפריסה בכותרות.

הוספת DOM דו-ממדי כדי לקבוע את מיקום הגלילה

במקום אירועי scroll, נשתמש ב-IntersectionObserver כדי לקבוע מתי headers ייכנסו למצב 'דביק' ויצאו ממנו. הוספת שני צמתים (שנקראים גם סנטינלים) לכל קטע במיקום קבוע, אחד למעלה והשני בחלק התחתון, ישמשו כנקודות ציון לזיהוי מיקום הגלילה. כשהסמנים האלה נכנסים למאגר ויוצאים ממנו, החשיפה שלהם משתנה ו-Intersection Observer מפעיל קריאה חוזרת (callback).

ללא אלמנטים של סנטינל
רכיבי הסנטינל המוסתרים.

אנחנו זקוקים לשני סנטימנטים כדי לכסות ארבעה מקרים של גלילה למעלה ולמטה:

  1. גלילה למטההכותרת נדבקת כשהסנטינל העליון של הקונטיינר חוצה את החלק העליון של הקונטיינר.
  2. גלילה למטהכותרת משאירה מצב 'דביק' כשהיא מגיעה לתחתית הקטע והסנטינל התחתון שלו חוצה את החלק העליון של הקונטיינר.
  3. גלילה למעלה – האפשרות header משאירה מצב 'דביק' כשהסנטינל העליון של הדף נגלל בחזרה לתצוגה מלמעלה.
  4. גלילה למעלההכותרת נשארת במיקום קבוע כשהסנטינל התחתון של המסך חוזר לתצוגה מלמעלה.

כדאי לראות הקלטת מסך מ-1 עד 4 לפי הסדר שבו הן מתרחשות:

צופי צומת מפעילים קריאות חוזרות (callbacks) כשהחיישנים נכנסים למאגר הגלילה או יוצאים ממנו.

שירות ה-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 יוצרת את צומתי ה-sentinel ומצרפת אותם לכל קטע. התצפית מחשבת את המקטע של סנטינל עם החלק התחתון של הקונטיינר ומחליטים אם הוא נכנס או יוצא. המידע הזה קובע אם כותרת הקטע תישאר בתוקף או לא.

/**
 * 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 Observers עשוי להועיל לצפייה בשינויים בסגנונות המחושבים של אלמנט. position: sticky.