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

אמ;לק

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

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

חדש: האירוע sticky-change

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

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

.sticky {
  position: sticky;
  top: 10px;
}

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

  1. החלת הטלת צללית על באנר בזמן שהוא מודבק.
  2. כשמשתמש קורא את התוכן שלכם, כדאי לתעד את ההיטים ב-Analytics כדי לדעת מהו ההתקדמות שלו.
  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;
});

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

בהדגמה, האפקטים חלים ללא scrollevents.

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

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

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

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

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

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

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

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

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

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

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

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

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

קובץ ה-CSS

השומרים ממוקמים בחלק העליון ובחלק התחתון של כל קטע. .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 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'));

לאחר מכן, הוספתי משתמש למעקב (observer) שיופעל כשרכיבי .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.