الگوهای تند کشیدن برای برداشتن در برنامههای تلفن همراه رایج هستند اما پیادهسازی تمیز آنها در وب میتواند چالشبرانگیز باشد. بااستفاده از CSS Scroll Snap، میتوانید تعامل تند کشیدن روان و با حس بومی ایجاد کنید که مستقیماً به موتور پیمایش مرورگر متصل میشود. این کار عملکرد بالا و تکانه مبتنی بر فیزیک را بدون نیاز به کتابخانه پیچیده اشارههای جاوا اسکریپت تضمین میکند.
همین الگو برای هر تند کشیدن تککنشی (برداشتن، بایگانی کردن، علامتگذاری بهعنوان خواندهشده، بهتعویق انداختن) کار میکند. تصاویر کنش تغییر میکند؛ مکانیکها تغییر نمیکند.
اگر میخواهید با تند کشیدن افقی، مجموعهای از دکمههای کنش یا منو کناری بدون انجام خودکار کنش درطول تند کشیدن باز شود، تند کشیدن برای آشکار کردن را ببینید.
نحوه پیادهسازی
این مؤلفه دو لایه دارد: فهرست (<ul>) و موارد داخل آن. هر مورد بهصورت یک <li> بیرونی، یک قطعه پیمایش داخلی (محفظه پیمایش با نقاط چسبیدن)، و یک عنصر محتوا (ردیف نمایان) ساختاردهی شده است. رابط کاربری آشکارشده کنش (نماد حذف، برچسب بایگانی، و غیره) در هر دو طرف محتوا قرار دارد. فهرست مالک سیمکشی مشترک است (راهاندازی تنبل مورد، انتخاب موارد جدیداً اضافهشده)؛ هر مورد مالک تشخیص تند کشیدن خودش است.
مرحله ۱: فهرست را با قطعه و محتوا نشانهگذاری کنید
<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>
مرحله ۲: پیکربندی قطعه بهعنوان محفظه چفت کردن افقی با سه نقطه چفت کردن
قطعه سه ستون با عرض کامل دارد: فاصلهانداز چپ (::before)، محتوا، و فاصلهانداز راست (::after). چسبیدن به فاصلهانداز یعنی محتوا کاملاً خارج از صفحه است که موقعیت استراحت پساز تند کشیدن قطعی است.
پیکربندی قطعه پشت کلاس .is-initialized در مورد فهرست قرار دارد. قبلاز اینکه JS ردیف را ارتقا دهد، مورد فقط بهعنوان محتوای ساده بدون پیمایشگر افقی ارائه میشود. کلاس در مرحله ۴ اضافه میشود، پساز اینکه جاوا اسکریپت که تند کشیدنها را شناسایی میکند، متصل شد. این کار تضمین میکند که کاربر نتواند قبلاز آماده شدن عملکرد تند بکشد.
.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;
}
مرحله ۳: نمادهای کنش را به مورد فهرست اضافه کنید
رنگ کنش روی فاصلهاندازها قرار دارد (مرحله ۲). نماد کنش روی خود مورد فهرست قرار دارد و به لبههای چپ و راست ردیف متصل است.
جایگذاری، اندازهبندی، و حرکت زیر پیشنهاد شروع است، نه الزام. اندازه نماد، حاشیههای داخلی، مقیاس آستانه پاپ، مدتزمان انتقال، و حتی انتخاب عناصر کاذب درمقابل گرههای 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;
}
مرحله ۴: تشخیص اشاره نهاییسازی با IntersectionObserver
از IntersectionObserver ریشهدار در قطعه استفاده کنید و محتوا را مشاهده کنید. وقتی کاربر تند میکشد، نسبت تقاطع محتوا با قطعه کاهش مییابد؛ ما از دو آستانه استفاده میکنیم:
activateThreshold: نسبت بالا (مثلاً ۰٫۸). وقتی بخش قابلمشاهده محتوا به کمتر از این مقدار میرسد، کاربر از نقطه فعالسازی دیداری عبور کرده است. نماد پاپآپ را روشن/خاموش کنید.-
commitThreshold: نسبت پایین (مثلاً ۰٫۲). وقتی بخش قابلمشاهده به کمتر از این مقدار برسد، کاربر متعهد شده است. پویانمایی برداشتن را فوراً شروع کنید، بدون اینکه منتظر بمانید اشاره سریع کاملاً انجام شود. ردیف جمعشونده با حرکت ادامهدار تند کشیدن کاربر ترکیب میشود که نسبتبه منتظر ماندن برای قرار گرفتن در جای خود قبلاز واکنش نشان دادن، واکنشگراتر بهنظر میرسد.
دو نگرانی دیگر در اینجا رسیدگی میشود:
- راهاندازی تنبل برای هر مورد: یک
IntersectionObserverبیرونی تکی که در نمای هدف ریشه دارد، راهاندازی و شروع/توقف ناظران تند کشیدن داخلی را هدایت میکند. موارد فقط اولین باری که با پیمایش در نما قرار میگیرند سیمکشی میشوند و ناظر تند کشیدن مواردی که با پیمایش از صفحه خارج میشوند موقتاً متوقف میشود. این کار تعداد ناظران فعال را محدود نگه میدارد و از خواندن مقادیر وابسته به چیدمان (مانندclientWidth) قبلاز رندر شدن مورد جلوگیری میکند. - Dynamic items: real lists grow over time (initial render, infinite scroll, server push).
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);
مرحله ۵: از کنش و برچسبی استفاده کنید که با مورد استفاده شما مطابقت دارد
برای انواع دیگر بهجز حذف، فقط عناصر دیداری و بدنه گرداننده ثبت تغییر میکند:
- بایگانی: پسزمینه سبز/آبی، نماد بایگانی، انتقال مورد به فهرست بایگانی بهجای برداشتن آن.
- علامتگذاری بهعنوان خواندهشده: پسزمینه کمرنگ، نماد علامت تیک، بهروزرسانی وضعیت مورد و بازپرداخت (یا فقط حذف کلاس
.unread). - بهتعویق انداختن: پسزمینه آبی، نماد ساعت، پنهان کردن تا زمان انتخابشده.
مکانیکهای پیمایش/میزان کردن/مشاهده بدون تغییر باقی میمانند.
کنشهای متفاوت برای هر جهت تند کشیدن
یک ردیف همچنین میتواند دو کنش مختلف را نشان دهد: یکی برای تند کشیدن به چپ و دیگری برای تند کشیدن به راست (مثلاً «بایگانی» در سمت راست، «حذف» در سمت چپ)، همانطور که بسیاری از برنامههای ایمیل بومی این کار را انجام میدهند. مکانیکهای پیمایش، میزان کردن، و تشخیص ثبت اصلاً تغییر نمیکنند — تنها چیزی که تغییر میکند اتفاقی است که در زمان ثبت رخ میدهد.
در مرحله ۴، شاخه commit همیشه removeItem(...) را فراخوانی میکند. برای پشتیبانی از دو کنش، گرداننده را براساس direction انتخاب کنید:
if (ratio < commitThreshold) {
const handler = direction === 'left' ? archiveItem : removeItem;
handler(item, content, direction, entry);
viewportObserver.unobserve(item);
observer.disconnect();
return;
}
نکتهای درباره نامگذاری: removeItem برای حالت مخرب نامگذاری شده است، اما تابعی که اجرا میکند (جمع کردن ارتفاع ردیف، لغزاندن محتوا به خارج از صفحه، سپس رها کردن گره) واقعاً یک روال عمومی «این ردیف انجام شده است، آن را با پویانمایی حذف کن» است. این ویژگی برای بایگانی کردن، علامتگذاری بهعنوان خواندهشده، یا بهتعویق انداختن نیز بهخوبی کار میکند — در هرصورت، ردیف از این فهرست حذف میشود. اگر گردانندههای شما درواقع چیزی را برنمیدارند (مثلاً هر دو مورد را به جای دیگری منتقل میکنند)، نام آن را به چیزی خنثی مانند dismissItem تغییر دهید تا کد بهدرستی خوانده شود.
برای متمایز کردن بصری این دو کنش، رنگ و نمادی را برای هر جهت روی مورد فهرست قرار دهید، سپس مسیر را با گرادیان تقسیمشده و دو نماد شبهعنصر از همان متغیرها رنگآمیزی کنید.
.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 از مرحله ۲ حذف کنید.
روالهای مطلوب و تلهها
- باید از
mandatorysnap استفاده کنید، نهproximity. باproximity، ردیف میتواند بهصورت نیمهپیمایششده باقی بماند و پسزمینه کنش را نیمهنمایان بگذارد. - باید
overscroll-behavior-x: noneرا در دسته هدف تنظیم کنید. بدون آن، تند کشیدن بیشازحد میتواند اشاره پیمایش به عقب مرورگر را در iOS/Android فعال کند. - باید در آستانهای قبلاز اینکه پرش به موقعیت نهایی برسد (مثلاً
commitThreshold ≈ 0.2) متعهد شوید، نه اینکه منتظر بمانید تا محتوا کاملاً از صفحه خارج شود. این کار باعث میشود پویانمایی حذف درطول اشاره شروع شود که بسیار واکنشگراتر از منتظر ماندن برای فرود آمدن ضربه سریع است. - باید راهاندازی هر مورد را از نمای بیرونی
IntersectionObserverانجام دهید، نه اینکه هر مورد را هنگام بارگیری صفحه متصل کنید. این کار از خواندن مقادیر وابسته به چیدمان (clientWidthو غیره) قبلاز ارائه شدن موارد جلوگیری میکند و تعداد ناظران فعال را متناسب با آنچه کاربر واقعاً میتواند ببیند نگه میدارد. - باید از
MutationObserverدر فهرست استفاده کنید وقتی موارد بهصورت پویا اضافه میشوند (پرداخت اولیه پساز بار شدن دادهها، پیمایش بیپایان، ارتباط فشاری سرور). بدون آن، مواردی که پساز بار شدن صفحه اضافه میشوند، متصل نخواهند شد. - برای انجام تبدیل دستی، به
pointerdown/pointermove/pointerupاتکا نکنید. تکانه، بازگشت سریع، دسترسپذیری صفحهکلید، و مدیریت کاهش حرکت را که مرورگر بهطور رایگان دراختیارتان قرار میدهد ازدست خواهید داد. - باید کنشهای مخرب را درصورت لزوم تأیید کنید. برای «برداشتن»، پساز تکمیل تند کشیدن، نمایش یک اعلان گذرا برای واگرد کردن را درنظر بگیرید؛ این اشاره سریع است و بهراحتی ممکن است بهطور تصادفی فعال شود.
- باید مطمئن شوید که نوار پیمایش قابلتمرکز و ازطریق صفحهکلید دردسترس باشد و امکان تمرکز دیداری وجود داشته باشد.
- باید برای هر کنش مربوطی که با تند کشیدن راهاندازی میشود، جایگزینهای دردسترسی ارائه دهید (مثلاً دکمه نمایان، منوِ بافت، یا حالت ویرایش).
راهبردهای جایگزین
همه ویژگیهای جدیدتری که استفاده میشوند یا برای تجربه اصلی نیستند یا جایگزینهای قویای دارند که اکنون میتوان بهطور مطمئن از آنها استفاده کرد.
گزینه جایگزین برای overscroll-behavior
برای این کاربرد، به بازگشت نیازی نیست. اگرچه overscroll-behavior مشکل تعاملپذیری دارد که در کانتینرهای بدون سرریز پیمایشی ظاهر میشود، اما قطعه در اینجا همیشه بهصورت افقی پیمایشپذیر است (سه ستون با عرض کامل در داخل یک کانتینر با عرض ۱۰۰٪)، بنابراین این ویژگی در همه مرورگرها بهطور یکسان رفتار میکند و اشاره تند کشیدن بهطور قابلاعتمادی مهار میشود.
گزینه جایگزین برای scrollbar-width
نوار پیمایش پنهان یک بهبود بصری است، نه مکانیزمی که باعث میشود عملکرد تند کشیدن برای برداشتن کار کند. اگر هدف «خط پایه» شما شامل scrollbar-width نباشد، ردیف همچنان پیمایش میکند، میزان میشود، ثبت را شناسایی میکند، و بهدرستی برمیدارد؛ تجربه پشتیبانینشده ممکن است فقط نوار پیمایش افقی را نشان دهد. اگر محصولتان در مرورگرهای قدیمیتر مشتقشده از WebKit به نوارهای پیمایش پنهان نیاز دارد، میتوانید یک قانون ::-webkit-scrollbar { display: none; } با دامنه محدود برای مسیر تند کشیدن اضافه کنید.
گزینه جایگزین برای scroll-initial-target
اگر هدف «خط پایه» شما شامل 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.
}
ترتیب تماسها مهم است. پیمایش برنامهریزیشده باید اجرا شود:
- AFTER
.is-initializedبه مورد فهرست اضافه میشود (کلاس چیزی است که قطعه را به محفظه پیمایش تبدیل میکند؛ تنظیمscrollLeftروی عنصر غیرقابلپیمایش بیتأثیر است). - قبلاز اینکه
IntersectionObserverداخلی از «مرحله ۴» پیوست شود. درغیراینصورت، پیمایش برنامهای اولیه از فاصلهانداز چپ بهعنوان «تند کشیدن» درنظر گرفته میشود و بلافاصله کنترلکننده ثبت را فعال میکند.
راهاندازی رانندگی از ناظر ناحیه نمایش بیرونی (مرحله ۴) چیزی است که این را قابلاطمینان میکند: setupItem پساز ارائه شدن مورد اجرا میشود، بنابراین track.clientWidth مقدار واقعی را بهجای 0 برمیگرداند.
برخیاز مرورگرها (بهویژه Safari) نیز هرگاه اندازه قطعه تغییر کند (نمایش/ پنهان کردن نوار نشانی وب، تغییر اندازه ناحیه نمایش، پُرسمانهای محفظه، و غیره) موقعیت پیمایش محفظه چسبان را بازنشانی میکنند. از 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.
}
Unobserve the track before the row's height animation runs in removeItem, otherwise the height change re-triggers the resize callback during removal. این را درکنار viewportObserver.unobserve(item) موجود در شاخه ثبت اضافه کنید:
if (ratio < commitThreshold) {
removeItem(item, content, direction, entry);
viewportObserver.unobserve(item);
if (needsScrollWorkaround) trackResizeObserver.unobserve(track);
observer.disconnect();
return;
}