UIDex

إجراءات السحب

Swipe Actions

.swipeActions / ItemTouchHelper

اسمه كمانسوايب أكشنز، أزرار السحب، إجراءات سحب الصفّ، الأزرار المخبّأة خلف الصفّ، السحب للحذف، إجراءات جانبية للصفّ، swipe buttons، swipe to reveal، row actions، swipe to delete، leading and trailing actions، swipeable list row

إجراءات السحب أزرار جالسة خلف صفّ في قائمة، تنكشف حين يجرّ المستخدم الصفّ أفقياً، ويبقى الصفّ في موضعه من القائمة. لكلّ حافّة من حافّتَي الصفّ مجموعتها الخاصّة: البادئة عادةً للإجراء الخفيف القابل للعكس، والخاتمة للإجراء الأثقل مثل الحذف. وهي ليست السحب للتحديث (pull to refresh)، فذاك يتحرّك على المحور الرأسي ويخصّ القائمة كلّها لا صفّاً بعينه. ولا هي قائمة السياق التي تفتحها الضغطة المطوّلة، لأن تلك قائمة مرئية لها موضع على الشاشة وعناصر مقروءة، بينما لا يظهر من إجراءات السحب حرف واحد قبل أن تبدأ الجرّة. وليست السحب للإزالة (swipe to dismiss)، فهذا الأخير نتيجة واحدة معلّقة على الإيماءة: تتجاوز العتبة فيغادر الصفّ القائمة، ولا يُكشف زرّ أصلاً. والأثر العملي لهذا كلّه أن إجراءات السحب تبقى غير مرئية حتى يعثر عليها أحد بالمصادفة، فكلّ ما تضعه فيها يحتاج طريقاً ثانياً يراه المستخدم بعينه.

لو قلت عليه…

«الأزرار اللي بتطلع لمّا تسحب الرسالة على جنب»«سحبة صغيرة وييجي زرار أحمر للحذف»«بتحذف من غير ما تفتح الرسالة أصلاً»«لمّا تشدّ الصفّ ناحية تطلع لك أرشفة»«الحاجات المخبّية ورا صفّ القائمة»

العيّنة الحيّة

تفاعل مع الديمو. كل الأجزاء حقيقية ومرقّمة.

٩:٤١

البريد

  • سارة عبد الله

    راجعت الملفّ وعندي ملاحظتان

  • هيئة النقل

    تذكرة رحلتك جاهزة للتحميل

  • مكتبة الحيّ

    موعد إرجاع الكتاب بعد يومين

RTLاسحب الصفّ. الحافّتان تتبادلان مع الاتجاه، وإزاحة المؤشّر لا تتبادل

تشريح العنصر: كل جزء واسمه

مرّر على أي سطر ليتحدّد مكانه في الديمو فوق.

برومبت البناء

ابنِ إجراءات سحب لصفوف قائمة. اجعل الصفّ نفسه هو المقبض وأعطه touch-action: pan-y حتى يبقى التمرير الرأسي للقائمة، ولا تفتح اللوح قبل عتبة أفقية لا تقلّ عن ١٠ بكسل حتى لا ينفتح مع كلّ جرّة مائلة. عرّف الحافّتين منطقياً: البادئة لإجراء واحد خفيف قابل للعكس، والخاتمة لإجراءين على الأكثر أثقلهما الحذف، وأعطِ كلّ زرّ عرضاً يكفي ٤٤ نقطة لمس ونصّاً بجانب الأيقونة. ثبّت لوح البادئة بـ inset-inline-start: 0 ولوح الخاتمة بـ inset-inline-end: 0، وحاذِ محتوى الزرّ بـ text-align: start و padding-inline-start، ولا تكتب left ولا right ولا margin-left في أيّ موضع. أزِح الصفّ بـ transform: translateX(calc(var(--reveal) * var(--dir) * -1)) واضبط ‎--dir: -1 داخل [dir="rtl"] لأن transform فيزيائية بلا مكافئ منطقي. واقرأ إشارة الاتجاه من getComputedStyle(el).direction قبل أيّ مقارنة على إزاحة المؤشّر، فالفرق في clientX يبقى فيزيائياً في الاتجاهين. اقصر السحب الكامل على أأمن إجراء وأطفئه على حافّة الحذف. واجعل كلّ إجراء متاحاً كذلك من زرّ ظاهر في الصفّ ومن قائمة الضغط المطوّل، وسجّله في accessibilityCustomActions على iOS وفي ViewCompat.addAccessibilityAction على أندرويد وفي زرّ حقيقي على الويب. أتبِع الحذف بشريط تراجع مدّته ٥ ثوانٍ على الأقلّ. وابدأ الجرّة بعيداً عن طرفَي الشاشة معاً حتى يبقى شريط إيماءة الرجوع للنظام، واحجز ذلك الشريط على أندرويد بـ View.setSystemGestureExclusionRects. وأغلق الصفّ المفتوح فور فتح صفّ آخر أو تمرير القائمة، ولا تقلب أيقونة السلّة أو الأرشيف عند انعكاس الاتجاه.

سلوكه في الاتجاه من اليمين لليسار

الجزء ده حصري عندنا.

البادئة والخاتمة تتبادلان

ينعكس

الحافّتان منطقيتان، فتتبادلان مواضعهما مع اتجاه الواجهة: ما عرّفته leading يظهر عند يمين الشاشة في العربية، وما عرّفته trailing يظهر عند يسارها. هذا سلوك المنصّة الصحيح لا خلل فيه، فـ SwiftUI تأخذ HorizontalEdge في ‎.swipeActions(edge:)‎، و UIKit يفصل بين tableView(_:leadingSwipeActionsConfigurationForRowAt:)‎ و tableView(_:trailingSwipeActionsConfigurationForRowAt:)‎، وكلاهما يُحلّ من الاتجاه الفعّال للواجهة. أمّا الفريق الذي كتب مواصفته بكلمتَي «يمين» و«يسار» فسيجد الحذف تحت الإبهام الخطأ بعد التعريب: الحركة التي تعوّد المستخدم أنها «أرشفة» صارت «حذف».

الإزاحة الخام تبقى فيزيائية

لا ينعكس

أرقام المؤشّر لا تعرف اتجاه الواجهة ولا تنقلب معه. الفرق بين clientX الحالي وقيمته عند بداية الجرّة موجب عند التحرّك يميناً في الاتجاهين معاً، و dX التي يمرّرها ItemTouchHelper.onChildDraw إزاحة فيزيائية كذلك، ومثلها ما تعيده UIPanGestureRecognizer.translation(in:)‎ لأن نظام إحداثيات العرض لا ينعكس. لذلك على كلّ تنفيذ يدوي أن يضرب الإزاحة في إشارة اتجاه قبل أن يقرّر أيّ حافّة انكشفت: getComputedStyle(el).direction === "rtl" ? -1 : 1 على الويب، و LocalLayoutDirection.current == LayoutDirection.Rtl في Compose، و traitCollection.layoutDirection == .rightToLeft في UIKit؛ أمّا المكوّنات الجاهزة فتتكفّل بهذا عنك، إذ ItemTouchHelper يعرّف START و END النسبيّتين ويحوّلهما إلى LEFT و RIGHT عبر convertToAbsoluteDirection حسب اتجاه التخطيط، بينما LEFT و RIGHT مطلقتان لا تنقلبان أبداً.

رسو اللوح ومحاذاة نصّه

ينعكس

اللوح المكشوف يرسو على الحافّة التي يخصّها، فثبّت لوح الخاتمة بـ inset-inline-end: 0 ولوح البادئة بـ inset-inline-start: 0 بدل right و left، فينتقلان وحدهما عند الانعكاس. وداخل كلّ زرّ يُبقي text-align: start مع padding-inline-start النصَّ والأيقونةَ في جهة القراءة، أمّا text-align: left فيلصق النصّ العربي بالطرف البعيد من زرّ عرضه ٦٠ بكسل فيبدو خللاً في العرض لا اختياراً في التصميم. أمّا إزاحة الصفّ فوق اللوح فبـ transform، وهي خاصّية فيزيائية بلا مكافئ منطقي في CSS، فاكتبها translateX(calc(var(--reveal) * var(--dir) * -1)) واضبط ‎--dir: -1 داخل [dir="rtl"]، وإلّا انزلق الصفّ فوق اللوح الخطأ وكشف الحافّة المقابلة.

أيقونات الإجراءات

لا ينعكس

أيقونات الإجراءات لا تنعكس: سلّة المهملات وصندوق الأرشيف والعلم والجرس رسوم لأشياء مادّية لا يتغيّر معناها باتجاه الصفحة، فقاعدة شاملة مثل [dir="rtl"] svg { transform: scaleX(-1) } تقلبها بلا سبب وتجعلها تبدو مرسومة بالمقلوب. الاستثناء أسهم «ردّ» و«إعادة توجيه» وحدها، ولا ينقلب منها شيء تلقائياً: اضبط android:autoMirrored="true" على الـ vector drawable في أندرويد، واستدعِ imageFlippedForRightToLeftLayoutDirection()‎ على UIImage في iOS، واقصر قاعدة scaleX(-1) على صنف تلك الأيقونة وحدها في الويب، ورموز SF التي لها نسخة RTL هي الوحيدة التي تتكفّل بنفسها.

التصادم مع إيماءة الرجوع

ينعكس

إيماءة الرجوع في iOS تبدأ من الحافّة البادئة، ومثلها إجراء الحافّة البادئة، فالتنافس بين interactivePopGestureRecognizer وبينه قائم في العربية كما كان في الإنجليزية: الاثنان ينعكسان معاً ولا يتبدّل غير الجانب. والحالة التي تُوقع الفرق هي المتصفّح، لأن Safari يحسب حافّة الرجوع من لغة واجهة المتصفّح لا من سمة dir في صفحتك، فصفحة عربية داخل Safari إنجليزي تبقى بادئتها يميناً بينما يظلّ شريط الرجوع يساراً. وعلى الويب يمنع touch-action: pan-y المتصفّحَ من سحب الصفّ لكنه لا يلغي إيماءة الحافّة، ولا سبيل إلى إلغائها من الصفحة أصلاً، فابدأ الجرّة بعيداً عن الطرفين معاً، وعلى أندرويد ١٠ فما فوق حيث الطرفان كلاهما منطقة رجوع احجز شريطك بـ View.setSystemGestureExclusionRects.

أسماؤه في الكود

كل سطر هو كلمة مكتبة واحدة عن نفس الشيء. خد السطر اللي بيكلّم مشروعك.

SwiftUI.swipeActions(edge: .leading, allowsFullSwipe: false)edge يأخذ HorizontalEdge، أي حافّة منطقية تنقلب مع اتجاه الواجهة؛ و allowsFullSwipe افتراضها true.
SwiftUIButton(role: .destructive) { … }يعطي الزرّ لون النظام الهدّام، وأوّل زرّ في المحتوى هو الذي يشغّله السحب الكامل.
UIKitUISwipeActionsConfiguration(actions:)تُعاد من دالّتَي leading و trailing المنفصلتين، و performsFirstActionWithFullSwipe افتراضها true فأطفئها على حافّة الحذف.
Jetpack ComposeSwipeToDismissBox(state, backgroundContent)لا يوفّر Material 3 مكوّناً مخصّصاً لإجراءات السحب، فهذا أساسها؛ وقيمتا SwipeToDismissBoxValue الاتجاهيتان هما StartToEnd و EndToStart وتُحلّان من LocalLayoutDirection.
AndroidXItemTouchHelper.SimpleCallback(0, START or END)START و END نسبيّتان يحوّلهما convertToAbsoluteDirection حسب اتجاه التخطيط، أمّا LEFT و RIGHT فمطلقتان لا تتبدّلان.
HTML<button type="button">الإيماءة لا تُعرض لأيّ تقنية مساعدة، فكرّر كلّ إجراء في زرّ حقيقي داخل قائمة فائض على الصفّ يراه المستخدم ويصله بلوحة المفاتيح.
CSStouch-action: pan-y / inset-inline-end: 0الأولى تترك المحور الرأسي للقائمة وتمنح الصفّ الأفقي، والثانية ترسي لوح الخاتمة على النهاية المنطقية فينتقل وحده عند الانعكاس.

شوف كمان

آخر تحديث · 2026-08-19