เหตุการณ์สำหรับตำแหน่ง CSS:Sticky

TL;DR

เคล็ดลับคือ คุณอาจไม่จําเป็นต้องใช้เหตุการณ์ scroll ในแอปถัดไป โดยใช้ IntersectionObserver เราจะแสดงวิธีเรียกเหตุการณ์ที่กําหนดเองเมื่อองค์ประกอบ position:sticky ได้รับการแก้ไขหรือเมื่อหยุดการแก้ไข ทั้งหมดนี้ทำได้โดยไม่ต้องใช้ Listeners ของการเลื่อน และยังมีวิดีโอสาธิตที่ยอดเยี่ยมเพื่อพิสูจน์ให้คุณเห็น

ดูการสาธิต | แหล่งที่มา

ขอแนะนํากิจกรรม sticky-change

ข้อจํากัดอย่างหนึ่งที่พบได้จริงของการใช้ตําแหน่งที่ติดแน่นของ CSS คือไม่ได้ส่งสัญญาณแพลตฟอร์มเพื่อระบุเมื่อพร็อพเพอร์ตี้ทำงานอยู่ กล่าวอีกนัยหนึ่งคือ ไม่มีเหตุการณ์ที่ทราบว่าองค์ประกอบหนึ่งติดหนึบเมื่อใด หรือเมื่อองค์ประกอบไม่ติดหนึบแล้ว

มาดูตัวอย่างต่อไปนี้ ซึ่งจะแก้ไข <div class="sticky"> 10px จากด้านบนของคอนเทนเนอร์หลัก

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

คงจะดีไม่น้อยหากเบราว์เซอร์บอกได้เมื่อองค์ประกอบถึงจุดนั้น ดูเหมือนว่าฉันไม่ใช่คนเดียวที่คิดเช่นนั้น สัญญาณสำหรับ position:sticky อาจช่วยปลดล็อกกรณีการใช้งานได้หลายกรณี ดังนี้

  1. ใช้เงาตกกระทบกับแบนเนอร์ขณะติดอยู่
  2. ขณะที่ผู้ใช้อ่านเนื้อหาของคุณ ให้บันทึก Hit ของ 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 จำลองเพื่อกำหนดตำแหน่งการเลื่อน

เราจะใช้ IntersectionObserver เพื่อพิจารณาว่าส่วนหัวจะเข้าสู่และออกจากโหมดติดหนึบเมื่อใดแทนการใช้เหตุการณ์ scroll การเพิ่มโหนด 2 โหนด (หรือที่เรียกกันว่า "ผู้รักษา" ในส่วนติดหนึบแต่ละส่วน ทั้ง 1 โหนดที่ด้านบนและอีก 1 โหนดที่ด้านล่างจะทำหน้าที่เป็นจุดอ้างอิงสำหรับการหาตำแหน่งการเลื่อน เมื่อเครื่องหมายเหล่านี้เข้าและออกจากคอนเทนเนอร์ ระดับการมองเห็นของมาร์กเกอร์จะเปลี่ยนไป และ Intersection Observer จะเรียกใช้การเรียกกลับ

ไม่มีองค์ประกอบ Sentinel ที่แสดง
องค์ประกอบ Sentinel ที่ซ่อนอยู่

เราต้องใช้ Sentinel 2 ตัวเพื่อครอบคลุมการเลื่อนขึ้นและลง 4 กรณี ดังนี้

  1. การเลื่อนลง - ส่วนหัวจะติดอยู่เมื่อ Sentinel ด้านบนตัดผ่านด้านบนของคอนเทนเนอร์
  2. การเลื่อนลง - ส่วนหัวจะออกจากโหมดติดแน่นเมื่อถึงด้านล่างของส่วน และเซ็นติเนลด้านล่างตัดผ่านด้านบนของคอนเทนเนอร์
  3. การเลื่อนขึ้น - ส่วนหัวจะออกจากโหมดติดหนึบเมื่อ Sentinel ด้านบนเลื่อนกลับมาอยู่ในมุมมองจากด้านบน
  4. การเลื่อนขึ้น - ส่วนหัวจะติดอยู่เมื่อ Sentinel ด้านล่างกลับมาแสดงจากด้านบน

เราขอแนะนำให้คุณเห็น Screencast ตัวเลข 1-4 ตามลำดับ

ผู้สังเกตการณ์ทางแยกจะเริ่มเรียกใช้ Callback เมื่อผู้เฝ้าระวังเข้า/ออกจากคอนเทนเนอร์แบบเลื่อน

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 Observer

Intersection Observer จะสังเกตการเปลี่ยนแปลงที่เกิดขึ้นแบบไม่พร้อมกันที่จุดตัดขององค์ประกอบเป้าหมายกับวิวพอร์ตของเอกสารหรือคอนเทนเนอร์หลัก ในกรณีนี้ เรากำลังสังเกตการซ้อนทับกับคอนเทนเนอร์หลัก

สูตรเด็ดคือ IntersectionObserver แต่ละยามจะมี IntersectionObserver สำหรับผู้สังเกตการณ์แสดงทางแยกภายในคอนเทนเนอร์แบบเลื่อน เมื่อ Sentinel เลื่อนไปยังวิวพอร์ตที่มองเห็นได้ เราจะทราบว่าส่วนหัวนั้นติดอยู่หรือหยุดติดแล้ว ในทํานองเดียวกัน เมื่อ Sentinel ออกจากวิวพอร์ต

ก่อนอื่น เราจะตั้งค่าผู้สังเกตการณ์สำหรับ Sentinel ส่วนหัวและส่วนท้าย

/**
 * 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 จะสร้าง Sentinel ด้านบนและเพิ่มลงในแต่ละส่วน ผู้สังเกตการณ์จะคํานวณจุดตัดของ Sentinel กับด้านบนของคอนเทนเนอร์ และตัดสินใจว่า Sentinel กำลังเข้าหรือออกจากวิวพอร์ต ข้อมูลดังกล่าวจะเป็นตัวกำหนดว่าส่วนหัวของส่วนจะติดอยู่หรือไม่

/**
 * 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] เพื่อให้การเรียกกลับทำงานทันทีที่ Sentinel ปรากฏขึ้น

กระบวนการนี้คล้ายกับของ Sentinel ด้านล่าง (.sticky_sentinel--bottom) ระบบจะสร้างเครื่องมือสังเกตการณ์ที่ 2 เพื่อทริกเกอร์เมื่อส่วนท้ายผ่านด้านล่างของคอนเทนเนอร์การเลื่อน ฟังก์ชัน 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] เพื่อให้การเรียกกลับทำงานเมื่อโหนดทั้งโหนดอยู่ในมุมมอง

สุดท้าย มียูทิลิตี 2 อย่างของผมสำหรับเริ่มเหตุการณ์ที่กำหนดเอง 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 จะเป็นเครื่องมือที่ช่วยแทนที่รูปแบบ UI ตามเหตุการณ์ของ scroll ที่มีการพัฒนาในช่วงหลายปีที่ผ่านมาได้ไหม คำตอบคือใช่และไม่ใช่ ความหมายของ API IntersectionObserver ทำให้ใช้งานทุกอย่างได้ยาก แต่อย่างที่เราแสดงไว้ที่นี่ คุณสามารถใช้เทคนิคที่น่าสนใจบางอย่างได้

มีวิธีอื่นในการตรวจหาการเปลี่ยนแปลงสไตล์ไหม

ไม่ครับ สิ่งที่เราต้องการคือวิธีสังเกตการเปลี่ยนแปลงสไตล์ในองค์ประกอบ DOM ขออภัย ไม่มี API ของแพลตฟอร์มเว็บที่ให้คุณติดตามการเปลี่ยนแปลงสไตล์ได้

MutationObserver น่าจะเป็นตัวเลือกแรกตามหลักเหตุผล แต่ใช้ไม่ได้กับกรณีส่วนใหญ่ ตัวอย่างเช่น ในตัวอย่างนี้ เราจะได้รับการเรียกกลับเมื่อมีการเพิ่มคลาส sticky ลงในองค์ประกอบ แต่จะไม่ได้รับการเรียกกลับเมื่อสไตล์ที่คอมไพล์แล้วขององค์ประกอบมีการเปลี่ยนแปลง โปรดทราบว่ามีการประกาศคลาส sticky ไปแล้วเมื่อโหลดหน้าเว็บ

ในอนาคต ส่วนขยาย "Style Mutation Observer" สำหรับ Mutation Observer อาจมีประโยชน์ในการสังเกตการเปลี่ยนแปลงสไตล์ที่คำนวณแล้วขององค์ประกอบ position: sticky