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

אמ;לק

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

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

היכרות עם האירוע 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;
});

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

בהדגמה, המערכת מחילה את האפקטים ללא אירועי Scrollevents.

להפעיל אפקטים של גלילה בלי אירועי גלילה?

מבנה הדף.
מבנה הדף.

לפני שנמשיך, אסביר על כמה מונחים כדי שאוכל להתייחס לשמות האלה בהמשך הפוסט:

  1. אזור גלילה – אזור התוכן (אזור התצוגה הגלוי) שמכיל את רשימת 'פוסטים בבלוג'.
  2. כותרות - כותרת כחולה בכל קטע שיש להן position:sticky.
  3. קטעים במיקום קבוע – כל קטע תוכן. הטקסט שגוללים מתחת לכותרות המוצמדות.
  4. 'מצב דביק' – כשהערך position:sticky חל על הרכיב.

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

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

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

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

ללא הצגת רכיבי סינון
הרכיבים המוסתרים של Sentinel.

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

  1. גלילה למטה – הכותרת הופכת לדביקה כשהחיישן העליון שלה חוצה את החלק העליון של המיכל.
  2. גלילה למטה – הכותרת יוצאת ממצב 'דבוק' כשהיא מגיעה לתחתית הקטע, והחיישן התחתון שלה חוצה את החלק העליון של המאגר.
  3. גלילה למעלההכותרת יוצאת ממצב דביק כשהחיישן העליון שלה גולל חזרה למעלה.
  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;
}

הגדרת צופים בין צמתים

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

רוטב הקסם הוא IntersectionObserver. לכל Sentinel מוקצה 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 התפתחו לאורך השנים. מסתבר שהתשובה היא כן ולא. סמנטיקה של ה-API של IntersectionObserver מקשה על השימוש בו לכל דבר. אבל כפי שציינתי כאן, אפשר להשתמש בו בשיטות מעניינות.

דרך אחרת לזהות שינויים בסגנון?

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

אפשרות MutationObserver היא אפשרות הגיונית בשלב הראשון, אבל זה לא מתאים ברוב המקרים. לדוגמה, בדמו, נקבל קריאה חוזרת (callback) כשהקלאס sticky יתווסף לרכיב, אבל לא כשהסגנון המחושב של הרכיב ישתנה. חשוב לזכור שכבר הכרזנו על הכיתה sticky במהלך טעינת הדף.

בעתיד, יכול להיות שתהיה אפשרות להשתמש בתוסף 'Style Mutation Observer' למעקב אחרי שינויים בסגנונות המחושבים של רכיבים. position: sticky.