การแพนกล้องแบบ 2 มิติ

ฟีเจอร์บนเว็บ: scroll-axis-lock overscroll-behavior

เมื่อผู้ใช้เลื่อนคอนเทนเนอร์การเลื่อนแบบ 2 มิติ โดยปกติแล้วเบราว์เซอร์จะใช้การคาดคะเนการล็อกแกนการเลื่อน (หรือที่เรียกว่า "การเลื่อนตามแนวแกน") หากท่าทางสัมผัสเริ่มต้นด้วยการเคลื่อนไหวตามแกนหนึ่งมากกว่าแกนที่ตั้งฉากอย่างมีนัยสำคัญ เบราว์เซอร์จะล็อกการเลื่อนไปยังแกนแนวนอนหรือแนวตั้งแกนเดียว และละเว้นเดลต้าที่ตั้งฉาก ซึ่งมักจะเป็นส่วนที่เหลือของท่าทางสัมผัส

แม้ว่าการล็อกแกนจะช่วยป้องกันการเลื่อนไปด้านข้างโดยไม่ตั้งใจในขั้นตอนการอ่านแบบมิติเดียว แต่ก็ทำให้เกิดความขัดแย้งในพื้นผิวการเลื่อนแบบ 2 มิติ เช่น แผนที่แบบอินเทอร์แอกทีฟ รูปภาพที่ซูม แคนวาสแบบไม่จำกัด กราฟโหนด และตารางกริดข้อมูลขนาดใหญ่ เนื่องจากการล็อกจะคงอยู่ตลอดท่าทางสัมผัส จึงส่งผลต่อการเลื่อนที่เกี่ยวข้องกับ 2 ทิศทาง ไม่ว่าจะเป็นการเลื่อนทั้ง 2 แกนพร้อมกัน (การเลื่อนแนวทแยง) หรือเลื่อนทีละแกนภายในท่าทางสัมผัสเดียว (เช่น เส้นทางโค้ง วงกลม หรือรูปตัว L) พร็อพเพอร์ตี้ CSS scroll-axis-lock จะปิดใช้การเรียนรู้จากประสบการณ์นี้โดยตรงในคอนเทนเนอร์การเลื่อนแบบเนทีฟ ซึ่งช่วยให้เลื่อนแบบ 2 มิติได้อย่างไม่จำกัด ขณะเดียวกันก็ยังคงรักษาการเลื่อนเทรดคอมโพสิเตอร์ ฟิสิกส์โมเมนตัม แถบเลื่อนแบบเนทีฟ และการไปยังส่วนต่างๆ ด้วยแป้นพิมพ์ไว้

การใช้งาน

1. ปิดใช้การล็อกแกนและจำกัดการเลื่อนเกินในคอนเทนเนอร์การเลื่อนแบบ 2 มิติ

ใช้ scroll-axis-lock: none กับองค์ประกอบที่สร้างคอนเทนเนอร์การเลื่อนแบบ 2 มิติ (overflow: auto หรือ overflow: scroll) โดยตรง แม้ว่าคอนเทนเนอร์การเลื่อนแบบ 2 มิติจะอัปเดตเดลต้าการเลื่อนตามแกนทั้ง 2 แกนภายใต้ค่า auto เริ่มต้นได้อยู่แล้ว แต่ auto จะช่วยให้การวิเคราะห์พฤติกรรมของราวของเบราว์เซอร์เริ่มทำงานเมื่อท่าทางสัมผัสเริ่มขึ้นตามแกนใดแกนหนึ่งเป็นหลัก การตั้งค่า scroll-axis-lock: none จะป้องกันไม่ให้แถบเลื่อนดังกล่าวทำงานในหน้าจอสัมผัส แทร็กแพด และลูกกลิ้งเมาส์

จับคู่ scroll-axis-lock: none กับ overscroll-behavior: none (หรือ overscroll-behavior: contain) ในคอนเทนเนอร์การเลื่อนเพื่อให้การเลื่อนไปถึงขอบไม่เชื่อมโยงการเลื่อนไปยังหน้าหลักหรือทริกเกอร์การไปยังส่วนต่างๆ ของเบราว์เซอร์ที่เลื่อนเกินโดยไม่ตั้งใจ (เช่น การไปยังส่วนต่างๆ ด้วยการปัดไปข้างหลัง/ข้างหน้าในแนวนอนหรือการดึงเพื่อรีเฟรช)

2. คงความสามารถในการโฟกัสของแป้นพิมพ์และการช่วยเหลือพิเศษไว้

เนื่องจากพื้นผิวการแพนแบบ 2 มิติมักไม่มีการควบคุมย่อยที่โฟกัสได้ (เช่น แผนที่หรือรูปภาพที่ซูม) โปรดตรวจสอบว่าผู้ใช้แป้นพิมพ์สามารถโฟกัสคอนเทนเนอร์และแพนด้วยแป้นลูกศรได้โดยเพิ่ม tabindex="0", role ที่เหมาะสมและป้ายกำกับที่เข้าถึงได้ รวมถึงตัวบ่งชี้ :focus-visible ที่มองเห็นได้

โค้ดตัวอย่าง

<!-- Make the 2D scroll container keyboard-focusable and labeled so keyboard
     users can focus the viewport and pan in all directions with Arrow keys. -->
<div class="pan-viewport" tabindex="0" role="region" aria-label="Interactive canvas">
  <div class="pan-surface">
    <!-- 2D overflowing content (map tiles, zoomed image, canvas nodes, or grid) -->
  </div>
</div>
.pan-viewport {
  /* Example viewport dimensions; adjust to fit your layout. */
  inline-size: 100%;
  block-size: 500px;

  /* MANDATORY: Establish a scroll container that can overflow on both axes. */
  overflow: auto;

  /* MANDATORY: Prevent browser scroll axis-locking ("railing") from kicking in
     so 2D gestures never get locked to a single axis. */
  scroll-axis-lock: none;

  /* Contain scrolling and prevent accidental swipe navigations at edges
     (use `none` or `contain`). */
  overscroll-behavior: none;
}

/* Provide a clear focus indicator when keyboard users focus the viewport. */
.pan-viewport:focus-visible {
  outline: 3px solid #005fcc;
  outline-offset: 2px;
}

.pan-surface {
  /* Example surface dimensions larger than .pan-viewport in both axes. */
  inline-size: 3000px;
  block-size: 3000px;
}

แนวทางปฏิบัติแนะนำและข้อจำกัด

  • ใช้กับคอนเทนเนอร์การเลื่อนเอง ไม่ใช่ Wrapper บรรพบุรุษ: scroll-axis-lock ไม่ได้รับการรับช่วง ดังนั้นการตั้งค่าใน Wrapper จึงไม่มีผลกับคอนเทนเนอร์การเลื่อนสืบทอด
  • จำกัดการเลื่อนเกินและป้องกันการไปยังส่วนต่างๆ โดยไม่ตั้งใจ: เพิ่ม overscroll-behavior: none หรือ overscroll-behavior: contain ลงในคอนเทนเนอร์การเลื่อน 2 มิติ เพื่อไม่ให้การเลื่อนเข้าไปในขอบเขตเชื่อมโยงกับหน้าหลักหรือทริกเกอร์การไปยังส่วนต่างๆ ด้วยการปัดไปข้างหลัง/ข้างหน้าของเบราว์เซอร์และท่าทางสัมผัสดึงเพื่อรีเฟรช
  • แผงแบบมิติเดียวที่ฝังอยู่จะยังคงใช้การจัดวางแบบรางโดยอัตโนมัติ: เนื่องจาก scroll-axis-lock ไม่รับค่า แถบด้านข้างหรือแผงเครื่องมือตรวจสอบที่ฝังอยู่ภายใน .pan-viewport จึงยังคงใช้ลักษณะการทำงานเริ่มต้นของ scroll-axis-lock: auto
  • ใช้ auto กับฟีดแนวตั้ง บทความ และรายการแบบมิติเดียวมาตรฐาน: การล็อกแกนจะป้องกันการเลื่อนในแนวตั้งฉากโดยไม่ตั้งใจในเนื้อหาแบบทิศทางเดียว
  • อย่าตั้งค่า touch-action: none โดยไม่มีเงื่อนไข: touch-action ควบคุมว่าเบราว์เซอร์จะจัดการท่าทางสัมผัสหรือไม่ (และไม่มีผลต่อการป้อนข้อมูลด้วยแทร็กแพดหรือล้อเลื่อน) ดังนั้นการตั้งค่านี้จะปิดใช้การเลื่อนด้วยการสัมผัสของคอมโพสิเตอร์แบบเนทีฟ ใช้เฉพาะภายใน @supports not (scroll-axis-lock: none) เมื่อใช้ตัวชี้ JavaScript สำรอง
  • เลื่อนด้วย scrollLeft / scrollTop ไม่ใช้ CSS transform: การแปลพื้นผิวด้วย transform จะทำให้องค์ประกอบไม่ทำงานเป็นคอนเทนเนอร์การเลื่อน ซึ่งจะทำให้แถบเลื่อนแบบเนทีฟ, การจำกัดขอบเขต, การเลื่อนด้วยแป้นลูกศรบนแป้นพิมพ์, เหตุการณ์ scroll, IntersectionObserver และภาพเคลื่อนไหวที่ขับเคลื่อนด้วยการเลื่อนใช้งานไม่ได้

กลยุทธ์สำรอง

เลือกระหว่างการเพิ่มประสิทธิภาพแบบต่อเนื่องและทางเลือกสำรองของ JavaScript โดยอิงตามข้อกำหนดของแอปพลิเคชัน ดังนี้

ตัวเลือกที่ 1: การเพิ่มประสิทธิภาพแบบต่อเนื่อง (ไม่มี JavaScript)

ใช้การเพิ่มประสิทธิภาพแบบต่อเนื่องเมื่อการรักษาการเลื่อนด้วยการสัมผัสในเทรด Compositor แบบเนทีฟ โมเมนตัมการเหวี่ยงแบบเฉื่อย และการบีบเพื่อซูมมีความสำคัญมากกว่าการกำจัดการล็อกแกนในเบราว์เซอร์ที่ไม่รองรับ ดังนี้

  • เบราว์เซอร์ที่รองรับ: เบราว์เซอร์จะป้องกันไม่ให้การเรียนรู้ของระบบการล็อกแกน ("การจำกัด") เริ่มทำงานในคอนเทนเนอร์การเลื่อน 2 มิติระหว่างท่าทางการเลื่อน
  • เบราว์เซอร์ที่ไม่รองรับ: เบราว์เซอร์ที่ไม่รู้จัก scroll-axis-lock: none จะละเว้นการประกาศอย่างปลอดภัยและคงลักษณะการเลื่อน 2 มิติ auto เริ่มต้นไว้ ซึ่งก็คือการเลื่อนทั้ง 2 แกนโดยค่าเริ่มต้น ขณะที่ยังคงจำกัดท่าทางสัมผัสที่เริ่มตามแกนเดียวเป็นหลัก

ตัวเลือกที่ 2: JavaScript สำรอง (wheel + pointermove พร้อม scrollLeft/scrollTop)

หากต้องการการเลื่อนแบบ 2 มิติที่ไม่มีข้อจำกัดในเบราว์เซอร์ทั้งหมด ให้ใช้ @supports not (scroll-axis-lock: none) และ CSS.supports('scroll-axis-lock', 'none') เพื่อสกัดกั้น wheel และท่าทางสัมผัสการลากเคอร์เซอร์ตามเงื่อนไข

เนื่องจาก wheel (e.deltaX, e.deltaY) และ pointermove (e.clientX, e.clientY) แสดงเดลต้า 2 มิติแบบดิบที่ไม่ได้ล็อกก่อนที่เครื่องมือการเลื่อนของเบราว์เซอร์จะใช้การล็อกแกน คุณจึงใช้เดลต้าเหล่านั้นกับ scrollLeft และ scrollTop ได้โดยตรง ดังนี้

  • เหตุผลที่ควรใช้ scrollLeft/scrollTop แทน CSS transform: การอัปเดต scrollLeft และ scrollTop จะทำให้องค์ประกอบยังคงเป็นคอนเทนเนอร์การเลื่อนที่แท้จริง เบราว์เซอร์จะจำกัดตำแหน่งการเลื่อนไว้ที่ [0, scrollWidth - clientWidth] และ [0, scrollHeight - clientHeight] โดยอัตโนมัติ แถบเลื่อนแบบเนทีฟจะยังคงซิงค์กัน การเลื่อนด้วยปุ่มลูกศรบนแป้นพิมพ์จะยังคงทำงานต่อไป และเหตุการณ์ scroll และ IntersectionObserver จะเริ่มทำงานตามปกติ
  • ข้อเสีย: การสกัดกั้น wheel ด้วย { passive: false } จะย้ายการเลื่อนไปยังเทรดหลัก และการตั้งค่า touch-action: none ในหน้าจอสัมผัสจะปิดใช้โมเมนตัมการเหวี่ยงตามแรงเฉื่อยแบบเนทีฟหลังจาก pointerup (รวมถึงการบีบเพื่อซูมแบบเนทีฟ)
/* Only disable native touch scrolling when scroll-axis-lock is unsupported
   and the JavaScript pointer fallback below is active. */
@supports not (scroll-axis-lock: none) {
  .pan-viewport {
    touch-action: none;
  }
}
// Only attach the main-thread gesture fallback when native support is missing
if (!CSS.supports('scroll-axis-lock', 'none')) {
  const viewport = document.querySelector('.pan-viewport');
  let lastPointer = null;

  // 1. Trackpad and mouse wheel fallback
  viewport.addEventListener(
    'wheel',
    (e) => {
      e.preventDefault();
      viewport.scrollLeft += e.deltaX;
      viewport.scrollTop += e.deltaY;
    },
    { passive: false },
  );

  // 2. Touch and pointer drag fallback
  viewport.addEventListener('pointerdown', (e) => {
    // Ignore non-primary buttons and clicks on native scrollbars
    const rect = viewport.getBoundingClientRect();
    if (
      !e.isPrimary ||
      e.button !== 0 ||
      e.clientX - rect.left - viewport.clientLeft >= viewport.clientWidth ||
      e.clientY - rect.top - viewport.clientTop >= viewport.clientHeight
    ) {
      return;
    }
    lastPointer = { x: e.clientX, y: e.clientY };
    viewport.setPointerCapture(e.pointerId);
  });

  viewport.addEventListener('pointermove', (e) => {
    if (!lastPointer) return;
    viewport.scrollLeft -= e.clientX - lastPointer.x;
    viewport.scrollTop -= e.clientY - lastPointer.y;
    lastPointer = { x: e.clientX, y: e.clientY };
  });

  const endPointerPan = () => {
    lastPointer = null;
  };
  viewport.addEventListener('pointerup', endPointerPan);
  viewport.addEventListener('pointercancel', endPointerPan);
}