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

אמ;לק

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  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'));

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

בעתיד, תוסף 'Style Mutation Observer' ל-Mutation Observers עשוי להועיל לצפייה בשינויים בסגנונות המחושבים של אלמנט. position: sticky.