حرکت افقی دوبعدی

ویژگی‌های وب: scroll-axis-lock overscroll-behavior

وقتی کاربر محتوای پیمایشی دوبعدی را پیمایش می‌کند، مرورگرها معمولاً از روش اکتشافی قفل کردن محور پیمایش (که به آن «ریل‌گذاری» نیز می‌گویند) استفاده می‌کنند. اگر اشاره‌ای با حرکت قابل‌توجهی در امتداد یک محور نسبت‌به محور عمود شروع شود، مرورگر پیمایش را روی همان محور افقی یا عمودی قفل می‌کند و دلتاهای عمود را نادیده می‌گیرد—اغلب برای بقیه اشاره.

درحالی‌که قفل کردن محور از جابه‌جایی تصادفی به طرفین در جریان‌های خواندن یک‌بعدی جلوگیری می‌کند، در سطوح جابه‌جایی دوبعدی مانند نقشه‌های تعاملی، تصاویر بزرگ‌نمایی‌شده، بوم‌های بی‌نهایت، نمودارهای گره، و شبکه‌های داده بزرگ اصطکاک ایجاد می‌کند. ازآنجایی‌که قفل درطول اشاره باقی می‌ماند، بر هرگونه حرکت عرضی که شامل دو جهت باشد تأثیر می‌گذارد—چه هر دو محور به‌طور هم‌زمان حرکت عرضی داشته باشند (حرکت عرضی مورب) یا یکی پس‌از دیگری در یک اشاره واحد (مانند مسیرهای منحنی، دایره‌ای، یا L شکل). ویژگی CSS scroll-axis-lock این اکتشافی را مستقیماً در محفظه پیمایش بومی غیرفعال می‌کند و امکان حرکت افقی دو بعدی نامحدود را فراهم می‌کند، درحالی‌که پیمایش رشته ترکیب‌کننده، فیزیک تکانه، نوارهای پیمایش بومی، و پیمایش صفحه‌کلید را حفظ می‌کند.

پیاده‌سازی

۱. غیرفعال کردن قفل محور و مهار کردن پیمایش بیش‌ازحد در محفظه پیمایش دوبعدی

scroll-axis-lock: none را مستقیماً روی عنصری که محفظه پیمایش دوبعدی را ایجاد می‌کند (overflow: auto یا overflow: scroll) اعمال کنید. اگرچه محفظه پیمایش دوبعدی می‌تواند دلتاهای پیمایش را در هر دو محور تحت مقدار پیش‌فرض auto به‌روز کند، auto به اکتشافی ریل‌گذاری مرورگر اجازه می‌دهد زمانی‌که اشاره عمدتاً در امتداد یک محور شروع می‌شود، فعال شود. تنظیم scroll-axis-lock: none از فعال شدن آن ریل در نمایشگرهای لمسی، پدهای لمسی، و چرخ‌های موشواره جلوگیری می‌کند.

‫scroll-axis-lock: none را با overscroll-behavior: none (یا overscroll-behavior: contain) در محفظه پیمایش جفت کنید تا رسیدن به لبه باعث زنجیره‌ای شدن پیمایش به صفحه اصلی نشود یا پیمایش‌های تصادفی فراتر از پیمایش مرورگر (مانند پیمایش تند کشیدن افقی به عقب/جلو یا کشیدن برای بازآوری) را فعال نکند.

۲. حفظ کردن تمرکزپذیری و دسترس‌پذیری صفحه‌کلید

ازآنجایی‌که سطح حرکت افقی دوبعدی اغلب فاقد کنترل‌های فرزند قابل‌تمرکز است (برای مثال، نقشه یا تصویر بزرگ‌نمایی‌شده)، با افزودن 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;
}

روال‌های مطلوب و محدودیت‌ها

  • آن را روی خود محفظه پیمایش اعمال کنید، نه روی محفظه پوشاننده والد: scroll-axis-lock به ارث برده نمی‌شود، بنابراین تنظیم آن روی محفظه پوشاننده هیچ تأثیری روی محفظه‌های پیمایش فرزند ندارد.
  • مهار کردن پیمایش بیش‌ازحد و جلوگیری از پیمایش‌های تصادفی: overscroll-behavior: none یا overscroll-behavior: contain را به محفظه پیمایش دوبعدی اضافه کنید تا حرکت افقی به مرز به صفحه اصلی زنجیر نشود یا پیمایش تند کشیدن به عقب/جلو مرورگر و اشاره‌های کشیدن برای بازآوری را راه‌اندازی نکند.
  • پانل‌های یک‌بعدی تودرتو به‌طور خودکار نرده‌کشی را حفظ می‌کنند: چون scroll-axis-lock به ارث نمی‌رسد، نوار کناری یا پانل بازرس تودرتو در .pan-viewport رفتار پیش‌فرض scroll-axis-lock: auto را حفظ می‌کند.
  • auto را در فیدهای عمودی استاندارد، مقاله‌ها، و فهرست‌های یک‌بعدی نگه دارید: قفل کردن محور از انحراف عمودی ناخواسته در محتوای یک‌جهته جلوگیری می‌کند.
  • touch-action: none را بدون قیدوشرط تنظیم نکنید: touch-action کنترل می‌کند که آیا مرورگر اشاره‌های لمسی را مدیریت می‌کند یا نه (و هیچ تأثیری بر ورودی پد لمسی یا چرخ ندارد)، بنابراین تنظیم آن پیمایش لمسی ترکیب‌کننده بومی را غیرفعال می‌کند. فقط هنگام پیاده‌سازی بازگشت اشاره‌گر جاوا اسکریپت، آن را در داخل @supports not (scroll-axis-lock: none) اعمال کنید.
  • با scrollLeft / scrollTop حرکت کنید، هرگز از CSS transform استفاده نکنید: ترجمه سطح با transform باعث می‌شود عنصر به‌عنوان محفظه پیمایش عمل نکند—این کار نوارهای پیمایش بومی، محدودیت‌های مرزی، پیمایش با کلیدهای جهت‌نمای صفحه‌کلید، رویدادهای scroll، IntersectionObserver، و پویانمایی‌های پیمایش‌محور را مختل می‌کند.

راهبردهای جایگزین

براساس الزامات برنامه‌تان، بین ارتقای پیش‌رو و جایگزین جاوا اسکریپت انتخاب کنید:

گزینه ۱: ارتقای پیش‌رو (بدون جاوا اسکریپت)

وقتی حفظ پیمایش لمسی رشته ترکیب‌کننده بومی، تکانه پرتاب اینرسی، و نیشگون برای بزرگ‌نمایی مهم‌تر از حذف قفل محور در مرورگرهای پشتیبانی‌نشده است، از ارتقای پیش‌رو استفاده کنید:

  • مرورگرهای پشتیبانی‌کننده: مرورگر از فعال شدن اکتشافی قفل محور («نرده‌گذاری») در ظرف پیمایش دوبعدی درطول اشاره‌های پیمایش جلوگیری می‌کند.
  • مرورگرهای پشتیبانی‌نشده: مرورگرهایی که scroll-axis-lock: none را تشخیص نمی‌دهند، به‌طور ایمن این اعلان را نادیده می‌گیرند و رفتار پیمایش دوبعدی پیش‌فرض auto خود را حفظ می‌کنند—پیمایش در هر دو محور به‌صورت بومی، درحالی‌که همچنان اشاره‌هایی را که عمدتاً در امتداد یک محور شروع می‌شوند، محدود می‌کنند.

گزینه ۲: جاوا اسکریپت Fallback (wheel + pointermove با scrollLeft/scrollTop)

اگر جابه‌جایی دو بعدی نامحدود در همه مرورگرها لازم است، از @supports not (scroll-axis-lock: none) و CSS.supports('scroll-axis-lock', 'none') برای رهگیری مشروط wheel و اشاره‌های کشیدن اشاره‌گر استفاده کنید.

چون wheel (e.deltaX، e.deltaY) و pointermove (e.clientX، e.clientY) دلتاهای دوبعدی خام و قفل‌نشده را قبل‌از اینکه موتور پیمایش مرورگر قفل محور را اعمال کند نشان می‌دهند، می‌توانید آن دلتاها را مستقیماً در 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);
}