אירוע עבור מיקום 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;
});

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

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

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

המבנה של הדף.
מבנה הדף.

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

  1. Scrolling container - אזור התוכן (אזור התצוגה הגלוי) שמכיל את הרשימה של 'blog posts' (פוסטים בבלוג).
  2. כותרות - כותרת כחולה בכל קטע שבו יש position:sticky.
  3. קטעים דביקים - כל קטע תוכן. הטקסט שגולל מתחת לכותרות הדביקות.
  4. "Sticky mode" - כאשר position:sticky מוחל על הרכיב.

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

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

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

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

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

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

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

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

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

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

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

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

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

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