กวาดนิ้วเพื่อนำออก

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

รูปแบบเดียวกันนี้ใช้ได้กับการปัดเพื่อดำเนินการเดียว (นำออก เก็บ ทำเครื่องหมายว่าอ่านแล้ว เลื่อนการแจ้งเตือน) ภาพการดำเนินการจะเปลี่ยนไป แต่กลไกจะไม่เปลี่ยนแปลง

หากต้องการให้การปัดในแนวนอนเปิดชุดปุ่มดำเนินการหรือเมนูด้านข้างโดยไม่ดำเนินการโดยอัตโนมัติระหว่างการปัด ให้ดูที่ปัดเพื่อแสดง

วิธีการนำไปใช้งาน

คอมโพเนนต์นี้มี 2 เลเยอร์ ได้แก่ รายการ (<ul>) และรายการภายใน แต่ละรายการจะมีโครงสร้างเป็น <li> ด้านนอก แทร็กเลื่อนด้านใน (คอนเทนเนอร์เลื่อนที่มีจุดสแนป) และองค์ประกอบเนื้อหา (แถวที่มองเห็นได้) UI ที่แสดงของการดำเนินการ (ไอคอนถังขยะ ป้ายกำกับที่เก็บถาวร ฯลฯ) จะอยู่ด้านใดด้านหนึ่งของเนื้อหา รายการเป็นเจ้าของการเดินสายไฟที่ใช้ร่วมกัน (การตั้งค่ารายการแบบ Lazy, การเลือกรายการที่เพิ่มใหม่) โดยแต่ละรายการเป็นเจ้าของการตรวจจับการปัดของตนเอง

ขั้นตอนที่ 1: มาร์กอัปรายการด้วยแทร็กและเนื้อหา

<ul class="SwipeableList">
  <li id="list-item-1" class="SwipeableList-item">
    <div class="SwipeableList-track">
      <div class="SwipeableList-content">Item One</div>
    </div>
  </li>
  <li id="list-item-2" class="SwipeableList-item">
    <div class="SwipeableList-track">
      <div class="SwipeableList-content">Item Two</div>
    </div>
  </li>
  <!-- ...more items... -->
</ul>

ขั้นตอนที่ 2: กำหนดค่าแทร็กเป็นคอนเทนเนอร์สแนปแนวนอนที่มีจุดสแนป 3 จุด

แทร็กมีคอลัมน์แบบเต็มความกว้าง 3 คอลัมน์ ได้แก่ ตัวเว้นวรรคด้านซ้าย (::before) เนื้อหา และตัวเว้นวรรคด้านขวา (::after) การสแนปไปยังตัวเว้นวรรคหมายความว่าเนื้อหาอยู่นอกหน้าจอโดยสมบูรณ์ ซึ่งเป็นตำแหน่งพักหลังจากปัดอย่างสมบูรณ์

การกำหนดค่าแทร็กจะถูกจำกัดไว้หลังคลาส .is-initialized ในรายการ ก่อนที่ JS จะอัปเกรดแถว รายการจะแสดงเป็นเนื้อหาธรรมดาโดยไม่มีแถบเลื่อนแนวนอน ระบบจะเพิ่มคลาสในขั้นตอนที่ 4 เมื่อเชื่อมต่อ JavaScript ที่ตรวจจับการปัดแล้ว วิธีนี้ช่วยให้มั่นใจได้ว่าผู้ใช้จะไม่ปัดก่อนที่ฟังก์ชันการทำงานจะพร้อมใช้งาน

.SwipeableList {
  list-style: none;
}

.SwipeableList-item {
  /* Establishes a containing block for the absolutely positioned action
     icons (Step 3) and clips overflow during the row's removal
     animation (Step 4). */
  contain: content;
}

/* The track only becomes a scroll snap container after JS upgrades
   the row by adding `.is-initialized` (see Step 4). */
.SwipeableList-item.is-initialized .SwipeableList-track {
  /* Three full-width columns: left spacer | content | right spacer.
     Width is 100% of the track, so each column fills the viewport row. */
  display: grid;
  grid-template-columns: 100% 100% 100%;

  /* Horizontal scroll only; vertical overflow is clipped so the
     reveal stays inside the row. */
  overflow: scroll clip;

  /* Prevent the swipe from chaining into the page scroll or browser
     back-gesture on iOS/Android. */
  overscroll-behavior-x: none;

  /* Hide the scrollbar; the gesture is the affordance. */
  scrollbar-width: none;

  /* `mandatory` ensures the track always rests on a snap point
     (spacer or content), never partially scrolled. */
  scroll-snap-type: x mandatory;
}

/* Spacers act as the left and right snap targets, AND carry the action's
   reveal color. As the user swipes, the colored spacer slides into view,
   which is what the user sees behind the content. */
.SwipeableList-item.is-initialized .SwipeableList-track::before,
.SwipeableList-item.is-initialized .SwipeableList-track::after {
  content: '';

  /* `scroll-snap-align` is required to make this a valid snap target,
     but the specific value (`start`/`center`/`end`) doesn't matter here
     because each snap point spans the full width of the scroll
     container, so all alignments resolve to the same resting position. */
  scroll-snap-align: start;

  /* `hsl(0 65% 50%)` is an example value; pick whatever fits your
     design (red here signals "delete"; use a different color for
     archive, mark-as-read, etc.). */
  background-color: hsl(0 65% 50%);
}

.SwipeableList-content {
  /* The content sits above the action icons (Step 3), so it covers
     them until the user swipes. */
  position: relative;
  z-index: 2;

  /* Required to make the content a valid snap target (its resting
     position). As with the spacers above, the specific value doesn't
     matter because the snap points are full-width. */
  scroll-snap-align: start;

  /* The content must paint over the revealed spacer color. */
  background: Canvas;

  /* Row separator (example value; customize to taste). */
  border-bottom: 1px solid #eee;
}

/* Gate `scroll-initial-target` behind `.is-initialized` so it only
   applies once the track is actually a scroll container. Setting it on
   the content before then would let the property walk up to the nearest
   scrollable ancestor (typically the document) and shift the page's
   initial scroll position to bring this row's content into view. With
   the gate, the rule is only live when the row's own track can satisfy
   it, so the initial scroll happens inside the track as intended. */
.SwipeableList-item.is-initialized .SwipeableList-content {
  scroll-initial-target: nearest;
}

/* The track is the focusable scroll container, but its overflow is clipped
   so a default focus ring on the track itself would be invisible. Project
   the focus affordance onto the content element (which paints above the
   track) using `:focus-visible` on the track. */
.SwipeableList-track:focus-visible .SwipeableList-content {
  outline: auto;
  outline-offset: -2px;
}

ขั้นตอนที่ 3: เพิ่มไอคอนการดำเนินการลงในรายการ

สีของการดำเนินการจะอยู่ในตัวเว้นวรรค (ขั้นตอนที่ 2) ไอคอนการดำเนินการจะอยู่ในรายการย่อย โดยยึดกับขอบด้านซ้ายและขวาของแถว

ตำแหน่ง ขนาด และการเคลื่อนไหวด้านล่างเป็นคำแนะนำเบื้องต้น ไม่ใช่ข้อกำหนด ปรับขนาดไอคอน, ระยะขอบ, ขนาดป๊อปอัปเกณฑ์, ระยะเวลาการเปลี่ยน และแม้แต่ตัวเลือกขององค์ประกอบจำลองเทียบกับโหนด DOM จริงเพื่อให้ตรงกับการออกแบบของคุณ ข้อกำหนดทางกลไกเพียงอย่างเดียวคือไอคอนต้องอยู่ด้านหลังเนื้อหา (เพื่อให้เนื้อหาปิดทับไอคอนก่อนปัด) และอยู่ภายในรายการ (เพื่อไม่ให้เลื่อนไปพร้อมกับตัวเว้นวรรค) ส่วนที่เหลือขึ้นอยู่กับรสนิยม

/* Action icons painted on the list item. They're absolutely positioned
   inside the row (which is a containing block thanks to `contain: content`
   on `.SwipeableList-item` from Step 2) and sit at z-index 1, so the
   content element (z-index 2) covers them until the user swipes far
   enough. */
.SwipeableList-item.is-initialized::before,
.SwipeableList-item.is-initialized::after {
  /* Inline an SVG as the action icon. Replace this with whatever icon
     fits the action (archive, checkmark, clock, etc.). The `fill='white'`
     is baked into the SVG so it contrasts with the red spacer background;
     adjust if your background color is light. */
  --action-icon: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='white'><path d='M9 3v1H4v2h1v13a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V6h1V4h-5V3H9zm0 5h2v9H9V8zm4 0h2v9h-2V8z'/></svg>");

  content: '';
  position: absolute;
  z-index: 1;

  /* The size (`width`) and insets (`left`/`right` below) are example
     values, tune them to match your row height and visual weight.
     The icon fills its box via `background-size: contain`, so changing
     `width` resizes it. */
  width: 1.5em; /* example value, adjust to taste */
  aspect-ratio: 1;
  top: 50%;
  translate: 0 -50%;

  /* Smooth transitions for the icon's visual states: the activate-point
     pop (`scale`, see `.is-activating` below) and the removal fade
     (`scale` + `opacity`, see `.is-removing` below). 0.2s is an example
     duration. */
  transition: scale 0.2s ease, opacity 0.2s ease;

  background: var(--action-icon) center / contain no-repeat;
}
/* Inset from the row edge (example values; adjust to match your layout). */
.SwipeableList-item.is-initialized::before { left: 1.5em; }
.SwipeableList-item.is-initialized::after  { right: 1.5em; }

/* Activating pop: scale the icon up when the user is past the visual
   activate point, so the row's affordance feels reactive. Toggled by JS
   in Step 4. */
.SwipeableList-item.is-activating::before,
.SwipeableList-item.is-activating::after {
  scale: 1.333;
}

/* Removal affordance: fade and shrink the icons as the row collapses.
   Driven by the `is-removing` class added in Step 4 at commit time;
   the existing `transition` on the icons animates the change. */
.SwipeableList-item.is-removing::before,
.SwipeableList-item.is-removing::after {
  scale: 0.5;
  opacity: 0;
}

/* Only show the icon on the *leading* side of the swipe; hide the
   trailing-side one. `data-swipe-direction` is set by JS in Step 4. */
.SwipeableList-item.is-activating[data-swipe-direction="left"]::after,
.SwipeableList-item.is-activating[data-swipe-direction="right"]::before {
  visibility: hidden;
}

ขั้นตอนที่ 4: ตรวจจับท่าทางสัมผัสเพื่อยืนยันด้วย IntersectionObserver

ใช้ IntersectionObserver ที่รูทที่แทร็กเพื่อสังเกตเนื้อหา เมื่อผู้ใช้ปัด อัตราส่วนการตัดกันของเนื้อหากับแทร็กจะลดลง เราใช้เกณฑ์ 2 รายการดังนี้

  • activateThreshold: อัตราส่วนสูง (เช่น 0.8) เมื่อส่วนที่มองเห็นได้ของเนื้อหาลดลงต่ำกว่านี้ แสดงว่าผู้ใช้ผ่านจุดเปิดใช้งานภาพไปแล้ว สลับการแสดงป๊อปอัปไอคอน
  • commitThreshold: อัตราส่วนต่ำ (เช่น 0.2) เมื่อส่วนที่มองเห็นได้ลดลงต่ำกว่านี้ แสดงว่าผู้ใช้ได้ดำเนินการแล้ว เริ่มภาพเคลื่อนไหวการนำออกทันทีโดยไม่ต้องรอให้ท่าทางสัมผัสการดีดนิ้วเสร็จสมบูรณ์ แถวที่ยุบจะผสานเข้ากับโมเมนตัมการปัดอย่างต่อเนื่องของผู้ใช้ ซึ่งให้ความรู้สึกตอบสนองได้ดีกว่าการรอให้การสแนปหยุดลงก่อนที่จะตอบสนอง

เราจะจัดการข้อกังวลอีก 2 ข้อที่นี่

  • การตั้งค่าแบบ Lazy Loading ต่อรายการ: IntersectionObserver ด้านนอกรายการเดียวที่รูทที่วิวพอร์ตจะขับเคลื่อนการตั้งค่าและการเริ่ม/หยุดของผู้สังเกตการณ์การปัดด้านใน ระบบจะเชื่อมต่อรายการต่างๆ เฉพาะเมื่อเลื่อนเข้ามาในมุมมองเป็นครั้งแรก และรายการที่เลื่อนออกนอกหน้าจอจะหยุดการสังเกตการณ์การปัดชั่วคราว ซึ่งจะช่วยจำกัดจำนวนผู้สังเกตการณ์ที่ทำงานอยู่และหลีกเลี่ยงการอ่านค่าที่ขึ้นอยู่กับเลย์เอาต์ (เช่น clientWidth) ก่อนที่รายการจะแสดงผล
  • รายการแบบไดนามิก: รายการจริงจะเพิ่มขึ้นเมื่อเวลาผ่านไป (การแสดงผลครั้งแรก, การเลื่อนได้ไม่รู้จบ, พุชจากเซิร์ฟเวอร์) MutationObserver ใน <ul> จะลงทะเบียนรายการที่เพิ่มใหม่กับผู้สังเกตการณ์ภายนอก
// Per-item handles. Populated when an item is first lazily wired up; read by
// the outer viewport observer to start/stop the inner observer as items enter
// and leave the viewport.
const swipeObservers = new WeakMap();

// Outer observer: drives the entire swipe lifecycle off viewport visibility.
// On first entry, lazily wires the item up (`setupItem` reads layout-dependent
// values like `clientWidth`, which return 0 until the item is rendered). After
// setup, starts the inner observer. On exit, stops the inner observer so
// offscreen items don't track scroll positions.
const viewportObserver = new IntersectionObserver((entries) => {
  for (const entry of entries) {
    const item = entry.target;
    if (entry.isIntersecting) {
      const handle = swipeObservers.get(item) ?? setupItem(item);
      handle.observer.observe(handle.content);
    } else {
      const handle = swipeObservers.get(item);
      if (handle) handle.observer.unobserve(handle.content);
    }
  }
});

function setupItem(item) {
  const track = item.querySelector('.SwipeableList-track');
  const content = track.querySelector('.SwipeableList-content');

  // Upgrade the row into "swipeable" mode. This is the gate for all the CSS
  // from Steps 2 and 3 (the track becomes a snap container, the action icons
  // appear). Done *before* the inner observer is attached so the snap
  // container exists by the time intersection callbacks can fire.
  item.classList.add('is-initialized');

  // Tunable thresholds. `activateThreshold` is the visual feedback point
  // (icon pops). `commitThreshold` is the point of no return: once the
  // content is past this point of being off-screen, we commit even if the
  // user releases mid-gesture and the track snaps back. A low value (~0.2)
  // commits before the snap settles, so the remove animation can start
  // during the swipe.
  const activateThreshold = 0.8;
  const commitThreshold = 0.2;

  // One inner observer per item, rooted at the track. Vertical scrolling of
  // the outer list moves root and target together, so the callback only fires
  // for the horizontal swipe.
  const observer = new IntersectionObserver((entries, observer) => {
    const entry = entries.at(-1);
    const ratio = entry.intersectionRatio;

    // Direction the user is swiping toward. A positive offset from the
    // track's left edge means the content has been pulled right (left
    // spacer revealed), so the leading icon is on the left.
    const direction = (entry.boundingClientRect.x - entry.rootBounds.x) > 0
      ? 'left'
      : 'right';

    if (ratio < commitThreshold) {
      // The IO entry's boundingClientRect is the last reliable measurement
      // before the animation starts; reuse it for both the pre-collapse
      // height and the slide-off translate distance.
      removeItem(item, content, direction, entry);
      viewportObserver.unobserve(item);
      observer.disconnect();
      return;
    }

    // Scale up the leading icon while the content is past the activate
    // point; restore it at rest.
    item.classList.toggle('is-activating', ratio < activateThreshold);

    // Hold the previous direction at rest so the icon's exit animation
    // finishes on the side the user was swiping toward.
    if (entry.boundingClientRect.x !== entry.rootBounds.x) {
      item.dataset.swipeDirection = direction;
    }
  }, {
    root: track,
    threshold: [commitThreshold, activateThreshold],
  });

  // Return the handle without starting observation; the outer viewport observer
  // calls `observer.observe(content)` once the item is in view.
  const handle = {observer, content};
  swipeObservers.set(item, handle);
  return handle;
}

async function removeItem(item, content, direction, entry) {
  const opts = { duration: 300, easing: 'ease', fill: 'forwards' };

  const rect = entry.boundingClientRect;
  // Content's pixel offset from the track's left edge.
  const x = rect.x - entry.rootBounds.x;
  // Pixel distance the content needs to travel to be fully out of view.
  const translate = direction === 'left'
    ? rect.width - x
    : -(x + rect.width);

  // Use a combination of CSS transitions (for declarative styles) and
  // WAAPI animations (for computed values) to remove the element,
  // then await the completion of all of them.
  // Note: the content translate animation is important because the
  // height-collapse animation can otherwise finish before the browser's
  // smooth scroll-snap has scrolled the content fully off-screen.
  item.classList.add('is-removing');
  item.animate([{ height: `${rect.height}px` }, { height: '0px' }], opts);
  content.animate([{ translate: `${translate}px` }], opts);
  await Promise.allSettled(
    item.getAnimations({ subtree: true }).map((a) => a.finished),
  );

  // Safari has a scroll-latching bug: removing the node while the swipe
  // gesture's momentum is still resolving causes the next item (which
  // slides up into this one's place) to inherit the scroll and
  // immediately scroll itself off-screen. Detect Safari via
  // `GestureEvent` (a Safari-only API) and defer the actual DOM removal
  // until the gesture has fully settled. The 5s delay is conservative;
  // anything longer than the momentum tail is fine. Mark the row inert
  // so it can't be interacted with during the delay.
  if (globalThis.GestureEvent) {
    item.inert = true;
    setTimeout(() => item.remove(), 5000);
  } else {
    item.remove();
  }
}

function setupList(list) {
  // Observe items already in the list.
  for (const item of list.children) {
    if (item.matches('.SwipeableList-item')) {
      viewportObserver.observe(item);
    }
  }

  // Pick up items added later (initial render after data loads, infinite
  // scroll, server push). Removals don't need MutationObserver handling —
  // the commit branch above already unobserves the item before removing it
  // from the DOM.
  new MutationObserver((mutations) => {
    for (const mutation of mutations) {
      for (const node of mutation.addedNodes) {
        if (node.nodeType === Node.ELEMENT_NODE &&
            node.matches('.SwipeableList-item')) {
          viewportObserver.observe(node);
        }
      }
    }
  }).observe(list, { childList: true });
}

document.querySelectorAll('.SwipeableList').forEach(setupList);

ขั้นตอนที่ 5: ใช้การดำเนินการและป้ายกำกับที่เหมาะกับกรณีการใช้งานของคุณ

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

  • เก็บถาวร: พื้นหลังสีเขียว/น้ำเงิน ไอคอนเก็บถาวร ย้ายรายการไปยังรายการเก็บถาวรแทนการนำออก
  • ทำเครื่องหมายว่าอ่านแล้ว: พื้นหลังสีอ่อน, ไอคอนเครื่องหมายถูก, อัปเดตสถานะรายการ และแสดงผลอีกครั้ง (หรือเพียงแค่นำคลาส .unread ออก)
  • เลื่อนการแจ้งเตือน: พื้นหลังสีน้ำเงิน ไอคอนนาฬิกา ซ่อนจนกว่าจะถึงเวลาที่เลือก

กลไกการเลื่อน/สแนป/การสังเกตการณ์จะไม่มีการเปลี่ยนแปลง

การดำเนินการที่แตกต่างกันตามทิศทางการปัด

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

ในขั้นตอนที่ 4 สาขาคอมมิตจะเรียกใช้ removeItem(...) เสมอ หากต้องการรองรับการดำเนินการ 2 รายการ ให้เลือกตัวแฮนเดิลตาม direction ดังนี้

if (ratio < commitThreshold) {
  const handler = direction === 'left' ? archiveItem : removeItem;
  handler(item, content, direction, entry);
  viewportObserver.unobserve(item);
  observer.disconnect();
  return;
}

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

หากต้องการทำให้การดำเนินการทั้ง 2 อย่างแตกต่างกันอย่างชัดเจน ให้ยกสีและไอคอนสำหรับแต่ละทิศทางไปยังรายการในลิสต์ จากนั้นลงสีแทร็กด้วยการไล่ระดับสีแบบแยกและไอคอนองค์ประกอบเทียม 2 รายการจากตัวแปรเดียวกัน

.SwipeableList-item {
  /* Action color + icon per swipe direction. `--left-*` is revealed
     when the user swipes RIGHT (e.g., archive); `--right-*` is revealed
     when the user swipes LEFT (e.g., delete). */
  --left-action-color: hsl(140 50% 40%);
  --left-action-icon: url("…archive svg…");
  --right-action-color: hsl(0 65% 50%);
  --right-action-icon: url("…trash svg…");
}

.SwipeableList-item.is-initialized .SwipeableList-track {
  /* Split reveal: left half of the scrollable area gets the left-action
     color, right half gets the right-action color. `background-attachment:
     local` makes the gradient's positioning area the scrollable area
     (3x the visible width), so the default size fills it and the 50% hard
     stop lines up exactly with the midpoint of the resting content column.
     The track's color also stays continuous behind the content as it
     translates off, so the leading-direction color shows the whole way. */
  background-image: linear-gradient(
    to right,
    var(--left-action-color) 50%,
    var(--right-action-color) 50%
  );
  background-attachment: local;
}

/* Per-side icons read from the same variables. */
.SwipeableList-item.is-initialized::before { background-image: var(--left-action-icon); }
.SwipeableList-item.is-initialized::after  { background-image: var(--right-action-icon); }

ด้วยการตั้งค่านี้ ตัวเว้นระยะไม่จำเป็นต้องมีสีพื้นหลังของตัวเองอีกต่อไป (การไล่ระดับสีของแทร็กจะจัดการการแสดงผล) ดังนั้นคุณจึงสามารถยกเลิกกฎ background-color ใน .SwipeableList-track::before, ::after จากขั้นตอนที่ 2 ได้หากใช้ตัวแปรแบบ 2 การทำงานนี้

แนวทางปฏิบัติแนะนำและข้อควรระวัง

  • ควรใช้ mandatory snap ไม่ใช่ proximity ใน proximity แถวสามารถเลื่อนได้บางส่วน ทำให้พื้นหลังการดำเนินการมองเห็นได้ครึ่งหนึ่ง
  • สิ่งที่ควรทำ: ตั้งค่า overscroll-behavior-x: none ในแทร็ก หากไม่มีการตั้งค่านี้ การปัดมากเกินไปอาจทริกเกอร์ท่าทางการนำทางย้อนกลับของเบราว์เซอร์ใน iOS/Android
  • ควรคอมมิตที่เกณฑ์ก่อนที่การสแนปจะเสร็จสิ้น (เช่น commitThreshold ≈ 0.2) แทนที่จะรอให้เนื้อหาอยู่นอกหน้าจอโดยสมบูรณ์ ซึ่งจะช่วยให้ภาพเคลื่อนไหวการนำออกเริ่มทำงานระหว่างการใช้ท่าทางสัมผัส ซึ่งให้ความรู้สึกตอบสนองได้ดีกว่าการรอให้การจัดวางเสร็จสิ้นอย่างมาก
  • ควรขับเคลื่อนการตั้งค่าต่อรายการจากวิวพอร์ตด้านนอก IntersectionObserver แทนที่จะเชื่อมต่อทุกรายการเมื่อโหลดหน้าเว็บ วิธีนี้จะช่วยหลีกเลี่ยงการอ่านค่าที่ขึ้นอยู่กับเลย์เอาต์ (clientWidth ฯลฯ) ก่อนที่ระบบจะแสดงผลรายการ และทำให้จำนวน Observer ที่ทำงานอยู่เป็นสัดส่วนกับสิ่งที่ผู้ใช้เห็นได้จริง
  • ควรใช้ MutationObserver ในรายการเมื่อมีการเพิ่มรายการแบบไดนามิก (การแสดงผลครั้งแรกหลังจากโหลดข้อมูล, การเลื่อนได้ไม่รู้จบ, พุชจากเซิร์ฟเวอร์) หากไม่มีโค้ดนี้ รายการที่เพิ่มต่อท้ายหลังจากโหลดหน้าเว็บจะไม่เชื่อมต่อกัน
  • อย่าใช้ pointerdown/pointermove/pointerup เพื่อขับเคลื่อนการเปลี่ยนรูปแบบด้วยตนเอง คุณจะสูญเสียโมเมนตัม การดีดกลับ การช่วยเหลือพิเศษของแป้นพิมพ์ และการจัดการการเคลื่อนไหวที่ลดลงซึ่งเบราว์เซอร์มีให้โดยไม่มีค่าใช้จ่าย
  • ควรยืนยันการดำเนินการที่ส่งผลกระทบเมื่อเหมาะสม สำหรับ "นำออก" ให้พิจารณาแสดงข้อความ Toast "เลิกทำ" หลังจากปัดเสร็จแล้ว เนื่องจากท่าทางสัมผัสนี้รวดเร็วและอาจเรียกใช้โดยไม่ตั้งใจได้ง่าย
  • ควรตรวจสอบว่าแถบเลื่อนสามารถโฟกัสได้ เข้าถึงได้ด้วยแป้นพิมพ์ และมีตัวบ่งชี้การโฟกัสที่มองเห็นได้
  • ควรจัดเตรียมทางเลือกที่เข้าถึงได้สำหรับการดำเนินการที่เกี่ยวข้องซึ่งทริกเกอร์โดยการปัด (เช่น ปุ่มที่มองเห็นได้ เมนูตามบริบท หรือโหมดแก้ไข)

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

ฟีเจอร์ใหม่ทั้งหมดที่ใช้ไม่ได้เป็นฟีเจอร์หลักของประสบการณ์การใช้งาน หรือมีฟีเจอร์สำรองที่มีประสิทธิภาพซึ่งสามารถใช้ได้อย่างน่าเชื่อถือในปัจจุบัน

สำรองสำหรับ overscroll-behavior

ไม่จำเป็นต้องใช้โฆษณาสำรองสำหรับกรณีการใช้งานนี้ แม้ว่า overscroll-behavior จะมีปัญหาด้านความสามารถในการทำงานร่วมกันซึ่งปรากฏในคอนเทนเนอร์ที่ไม่มีส่วนที่ล้นออกมาแบบเลื่อนได้ แต่แทร็กที่นี่จะเลื่อนในแนวนอนได้เสมอ (คอลัมน์ความกว้างเต็ม 3 คอลัมน์ภายในคอนเทนเนอร์ความกว้าง 100%) ดังนั้นพร็อพเพอร์ตี้จึงทำงานอย่างสอดคล้องกันในเบราว์เซอร์ต่างๆ และท่าทางการปัดจะถูกจำกัดไว้อย่างน่าเชื่อถือ

สำรองสำหรับ scrollbar-width

แถบเลื่อนที่ซ่อนอยู่เป็นการปรับปรุงภาพ ไม่ใช่กลไกที่ทำให้การปัดเพื่อนำออกทำงานได้ หากเป้าหมาย Baseline ของคุณไม่มี scrollbar-width แถวจะยังคงเลื่อน สแนป ตรวจจับการคอมมิต และนำออกอย่างถูกต้อง ประสบการณ์ที่ไม่รองรับอาจแสดงแถบเลื่อนแนวนอน หากผลิตภัณฑ์ของคุณต้องใช้แถบเลื่อนที่ซ่อนอยู่ในเบราว์เซอร์ที่มาจาก WebKit รุ่นเก่า คุณสามารถเพิ่มกฎ ::-webkit-scrollbar { display: none; } ที่มีขอบเขตแคบสำหรับแทร็กการปัดได้

สำรองสำหรับ scroll-initial-target

หากเป้าหมาย Baseline ไม่มี scroll-initial-target ให้เลื่อนแทร็กไปยังเนื้อหาแบบเป็นโปรแกรมภายใน setupItem ตรวจจับด้วย CSS.supports:

// Hoist the feature detect so the conditional `ResizeObserver` below can be
// skipped entirely when the property is supported.
const needsScrollWorkaround = !CSS.supports('scroll-initial-target', 'nearest');

function setupItem(item) {
  // ...existing setup from Step 4...

  if (needsScrollWorkaround) {
    track.scrollLeft = track.clientWidth;
  }

  // ...attach the inner IntersectionObserver, etc.
}

ลำดับการโทรมีความสำคัญ การเลื่อนแบบเป็นโปรแกรมต้องทำงานในกรณีต่อไปนี้

  1. AFTERเพิ่ม .is-initialized ลงในรายการ (คลาสคือสิ่งที่เปลี่ยนแทร็กให้เป็นคอนเทนเนอร์แบบเลื่อนได้ การตั้งค่า scrollLeft ในองค์ประกอบที่เลื่อนไม่ได้จะไม่มีผล)
  2. ก่อนที่จะติด IntersectionObserver ด้านในจากขั้นตอนที่ 4 มิฉะนั้น ระบบจะถือว่าการเลื่อนแบบเป็นโปรแกรมครั้งแรกผ่านตัวเว้นวรรคด้านซ้ายเป็นการ "ปัด" และจะเริ่มทำงานตัวแฮนเดิลการคอมมิตทันที

การตั้งค่าการขับเคลื่อนจากผู้สังเกตการณ์วิวพอร์ตภายนอก (ขั้นตอนที่ 4) คือสิ่งที่ทำให้วิธีนี้เชื่อถือได้ โดย setupItem จะทำงานหลังจากแสดงผลรายการแล้ว ดังนั้น track.clientWidth จะแสดงผลค่าจริงแทน 0

เบราว์เซอร์บางประเภท (โดยเฉพาะ Safari) ยังรีเซ็ตตำแหน่งการเลื่อนของคอนเทนเนอร์สแนปเมื่อใดก็ตามที่แทร็กปรับขนาด (แสดง/ซ่อนแถบ URL, ปรับขนาดวิวพอร์ต, การค้นหาคอนเทนเนอร์ ฯลฯ) ใช้ ResizeObserver ในแต่ละแทร็กเพื่อใช้การเลื่อนอีกครั้ง จำกัดไว้หลังการตรวจสอบ CSS.supports เดียวกัน เมื่อรองรับ scroll-initial-target เบราว์เซอร์จะจัดการการคืนค่าการเลื่อนเมื่อปรับขนาดเอง

const trackResizeObserver = needsScrollWorkaround
  ? new ResizeObserver((entries) => {
      for (const entry of entries) {
        entry.target.scrollLeft = entry.target.clientWidth;
      }
    })
  : null;

function setupItem(item) {
  // ...existing setup...

  if (needsScrollWorkaround) {
    track.scrollLeft = track.clientWidth;
    trackResizeObserver.observe(track);
  }

  // ...attach the inner IntersectionObserver, etc.
}

เลิกสังเกตแทร็กก่อนที่ภาพเคลื่อนไหวความสูงของแถวจะทำงานใน removeItem ไม่เช่นนั้นการเปลี่ยนแปลงความสูงจะทริกเกอร์การเรียกกลับการปรับขนาดอีกครั้งระหว่างการนำออก เพิ่มโค้ดนี้ควบคู่ไปกับ viewportObserver.unobserve(item) ที่มีอยู่ใน Commit Branch

if (ratio < commitThreshold) {
  removeItem(item, content, direction, entry);
  viewportObserver.unobserve(item);
  if (needsScrollWorkaround) trackResizeObserver.unobserve(track);
  observer.disconnect();
  return;
}