UIDex

Swipe Actions

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

.swipeActions / ItemTouchHelper

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

Swipe actions are buttons parked behind a row in a list. They come into view when the user drags the row sideways, and the row keeps its place in the list the whole time. Each of the two row edges carries its own set: the leading edge usually holds the light, reversible action, and the trailing edge holds the heavier one such as delete. It is not pull to refresh, which travels on the vertical axis and belongs to the whole list rather than to a single row. A long-press context menu is a different thing again: once it opens it is on screen, with a position and readable items, while nothing about a swipe action is visible before the drag begins. Swipe to dismiss is not this either, because there one outcome is wired straight to the gesture: cross the threshold and the row leaves the list, with no button revealed at any point. The practical consequence is that swipe actions stay invisible until somebody stumbles onto them, so everything you put behind a row needs a second route the user can actually see.

If you called it…

"the red delete button that slides out of a row""dragging an email sideways to archive it""the hidden buttons behind a list row""you pull the row across and options appear""deleting from the list without opening it"

Live specimen

Interact with the demo. Every part is real and numbered.

٩:٤١

البريد

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

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

  • هيئة النقل

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

  • مكتبة الحيّ

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

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

Anatomy: every part, named

Hover a row to locate it in the demo above.

Build prompt

Build swipe actions for list rows. Make the row itself the handle, give it touch-action: pan-y so vertical scrolling still belongs to the list, and do not open the panel until the drag passes a horizontal threshold of at least 10px so a diagonal flick does not trigger it. Define the edges logically: one light reversible action on the leading edge, at most two on the trailing edge with delete as the heavier one, each button wide enough for a 44pt touch target and labelled with a word beside the icon. Pin the leading panel with inset-inline-start: 0 and the trailing panel with inset-inline-end: 0, align button content with text-align: start and padding-inline-start, and never write left, right, or margin-left anywhere. Push the row with transform: translateX(calc(var(--reveal) * var(--dir) * -1)) and set --dir to -1 under [dir="rtl"], since transform has no logical counterpart. Read a direction sign from getComputedStyle(el).direction before comparing any pointer delta, because the difference in clientX stays physical in both directions. Reserve full swipe for the safest action and disable it on the edge that holds delete. Expose every action from a visible button in the row and from a long-press menu as well, and register it in accessibilityCustomActions on iOS, in ViewCompat.addAccessibilityAction on Android, and as a real button on the web. Follow every delete with an undo bar that lasts at least five seconds. Start the drag clear of both screen edges so the system back gesture keeps its strip, and reserve that strip on Android with View.setSystemGestureExclusionRects. Close an open row as soon as another row opens or the list scrolls, and never mirror the trash or archive icon when direction flips.

How it behaves right-to-left

This section is ours alone.

Leading and trailing swap

mirrors

The two edges are logical, so they trade places with interface direction: what you defined as leading shows up on the right of the screen in Arabic, and what you defined as trailing shows up on the left. That is the platform behaving correctly, because SwiftUI takes a HorizontalEdge in .swipeActions(edge:), UIKit splits tableView(_:leadingSwipeActionsConfigurationForRowAt:) from tableView(_:trailingSwipeActionsConfigurationForRowAt:), and both resolve against the effective interface layout direction. A team that wrote its spec with the words left and right will find delete under the wrong thumb after localisation: the motion the user learned as archive now means delete.

Raw deltas stay physical

never mirrors

Pointer numbers know nothing about interface direction and never flip with it. Current clientX minus its value at drag start is positive for rightward motion in both directions, the dX handed to ItemTouchHelper.onChildDraw is a physical displacement too, and so is whatever UIPanGestureRecognizer.translation(in:) returns, because the view coordinate system is not mirrored. Any hand-rolled implementation therefore has to multiply the delta by a direction sign before deciding which edge was revealed: getComputedStyle(el).direction === "rtl" ? -1 : 1 on the web, LocalLayoutDirection.current == LayoutDirection.Rtl in Compose, traitCollection.layoutDirection == .rightToLeft in UIKit; ready-made components do it for you, since ItemTouchHelper defines the relative START and END and converts them to LEFT and RIGHT through convertToAbsoluteDirection according to layout direction, while LEFT and RIGHT are absolute and never move.

Where the panel anchors

mirrors

The revealed panel anchors to the edge it belongs to, so pin the trailing panel with inset-inline-end: 0 and the leading panel with inset-inline-start: 0 rather than right and left, and both move on their own when the page flips. Inside each button, text-align: start plus padding-inline-start keeps the label and the icon on the reading side; text-align: left strands an Arabic label at the far end of a 60px button and reads as a rendering fault. The row is pushed over the panel with transform, a physical property with no logical counterpart in CSS, so write it as translateX(calc(var(--reveal) * var(--dir) * -1)) with --dir set to -1 under [dir="rtl"], or the row slides over the wrong panel and uncovers the opposite edge.

The action icons

never mirrors

Action icons do not mirror. A trash can, an archive box, a flag, and a bell are pictures of physical objects whose meaning does not change with page direction, so a blanket [dir="rtl"] svg { transform: scaleX(-1) } flips them for no reason and leaves them looking drawn back to front. Reply and forward arrows are the exception, and nothing flips them on your behalf: set android:autoMirrored="true" on the vector drawable, call imageFlippedForRightToLeftLayoutDirection() on the UIImage, and on the web scope a scaleX(-1) rule to that one icon class, since SF Symbols with an RTL variant are the only icons that handle themselves.

Clashing with the back gesture

mirrors

The iOS back swipe starts at the leading edge and so does a leading swipe action, so interactivePopGestureRecognizer competes with it in Arabic exactly as it did in English: the pair mirrors together and only the side changes. The browser is the case that catches people out, because Safari resolves its back-swipe edge from the browser UI language and not from the dir attribute on your page, so an Arabic page inside an English Safari keeps its leading edge on the right while the back strip stays on the left. On the web, touch-action: pan-y stops the browser panning the row but cannot cancel that edge swipe, and no page-level API can, so start the drag away from both edges, and on Android 10 and later, where both edges are back zones, claim your strip with View.setSystemGestureExclusionRects.

In code

Each row is one framework's word for the same thing. Take the row your project speaks.

SwiftUI.swipeActions(edge: .leading, allowsFullSwipe: false)edge takes a HorizontalEdge, a logical edge that moves with interface direction; allowsFullSwipe defaults to true.
SwiftUIButton(role: .destructive) { … }Gives the button the system destructive tint, and the first button in the content is the one full swipe fires.
UIKitUISwipeActionsConfiguration(actions:)Returned from the separate leading and trailing delegate methods; performsFirstActionWithFullSwipe defaults to true, so switch it off on the delete edge.
Jetpack ComposeSwipeToDismissBox(state, backgroundContent)Material 3 ships no dedicated swipe-actions component, so this is what you build one on; the directional cases of SwipeToDismissBoxValue are StartToEnd and EndToStart, both resolved from LocalLayoutDirection.
AndroidXItemTouchHelper.SimpleCallback(0, START or END)START and END are relative and converted by convertToAbsoluteDirection per layout direction; LEFT and RIGHT are absolute and never swap.
HTML<button type="button">The gesture is exposed to no assistive technology, so repeat every action in a real button, usually inside an overflow menu on the row, and keep it keyboard reachable.
CSStouch-action: pan-y / inset-inline-end: 0The first leaves the vertical axis to the list and gives the row the horizontal one; the second anchors the trailing panel to the logical end so it moves on its own when direction flips.

See also

Updated · 2026-08-19