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

สรุปคร่าวๆ

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

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

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

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

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

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

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

  1. ใช้เงาตกกระทบบนแบนเนอร์ขณะที่ติดอยู่
  2. ขณะที่ผู้ใช้อ่านเนื้อหาของคุณ ให้บันทึก Hit จาก Analytics เพื่อดูความคืบหน้า
  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. การเลื่อนคอนเทนเนอร์ - พื้นที่เนื้อหา (วิวพอร์ตที่มองเห็นได้) ที่มีรายการ "บล็อกโพสต์"
  2. ส่วนหัว - ชื่อสีน้ำเงินในแต่ละส่วนที่มี position:sticky
  3. ส่วนที่มีความสามารถในการดึงดูด - ส่วนของเนื้อหาแต่ละส่วน ข้อความที่เลื่อนลงมาใต้ ส่วนหัวแบบติดหนึบ
  4. "โหมดกดค้าง" - เมื่อ position:sticky มีผลกับองค์ประกอบ

เพื่อให้รู้ว่าส่วนหัวใดเข้าสู่ "โหมดกดค้าง" เราจะต้องกำหนดออฟเซ็ตการเลื่อนของคอนเทนเนอร์การเลื่อน ซึ่งจะช่วยให้เราคำนวณส่วนหัวที่กำลังแสดงอยู่ได้ แต่นั่นถือว่าค่อนข้างซับซ้อนหากไม่มีเหตุการณ์ scroll :) ส่วนปัญหาอื่นๆ คือ position:sticky จะนำองค์ประกอบออกจากเลย์เอาต์เมื่อแก้ไขแล้ว

เราจึงสูญเสียความสามารถในการคำนวณเกี่ยวกับเลย์เอาต์ในส่วนหัวหากไม่มีเหตุการณ์การเลื่อน

การเพิ่ม DOM Dumby เพื่อกำหนดตำแหน่งการเลื่อน

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

ไม่มีองค์ประกอบ Sentinel
องค์ประกอบของเซนติเนลที่ซ่อนไว้

เราต้องการเจ้าหน้าที่ 2 คนเพื่อให้ครอบคลุมกรณีการเลื่อนขึ้นลง 4 กรณี ดังนี้

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

การดู Screencast ที่มี 1-4 ตามลำดับที่แสดงขึ้นก็มีประโยชน์

Intersection Observers เรียกใช้โค้ดเรียกกลับเมื่อผู้พิทักษ์ เข้า/ออกจากคอนเทนเนอร์การเลื่อน

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

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

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

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

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