SwipeActions adds swipe actions to any SwiftUI view, not just List rows: cards in a ScrollView, rows in a LazyVStack or a plain VStack. Leading and trailing actions, full swipe with a haptic at the threshold, destructive actions that animate the row out, three reveal styles, rounded card buttons, rubber banding, one open row at a time, tap outside to close, right-to-left layouts and VoiceOver actions. No dependencies.
import SwiftUI
import SwipeActions
struct InboxView: View {
@State private var inbox = Inbox()
@State private var swipeGroup = SwipeGroup()
var body: some View {
ScrollView {
LazyVStack(spacing: 12) {
ForEach(inbox.mails) { mail in
MailCard(mail: mail)
.swipeable(id: mail.id) {
SwipeAction("Unread", systemImage: "envelope.badge.fill", tint: .blue) {
inbox.toggleRead(mail)
}
} trailing: {
SwipeAction("Delete", systemImage: "trash.fill", role: .destructive) {
inbox.delete(mail)
}
SwipeAction("Archive", systemImage: "archivebox.fill", tint: .indigo) {
inbox.archive(mail)
}
}
}
}
.padding(.horizontal)
}
.swipeOptions(.cards)
.swipeGroup(swipeGroup)
}
}Captured by CI from the example app on an iPhone simulator. The messages and tasks are a bundled demo dataset written for the example app (generic senders, subjects and to-dos), not real mail. Rows are opened through SwipeGroup.open(_:edge:) and the full swipe is frozen with swipeDebugProgress(_:edge:), so every capture is the same on every run.
| Trailing actions | Leading actions | Full swipe | Card list |
|---|---|---|---|
![]() |
![]() |
![]() |
![]() |
- Any view: a
.swipeable { } trailing: { }modifier for cards, custom rows and anything else, inScrollView,LazyVStackorVStack. It does not clash with SwiftUI'sswipeActions. - Leading and trailing actions built with
if,if/else,switchandfor. - Full swipe: swiping far enough runs the edge action, with a haptic as you cross the threshold. Per edge, with an adjustable threshold.
- Destructive role: the row slides out and collapses, then the action runs so you can remove the item.
- Three reveal styles:
.reveal(buttons stay put under the view),.drag(buttons slide in with it) and.expand(buttons share the uncovered space). - Custom looks: edge-to-edge rectangles or rounded card buttons with gaps, icon and title, icon only or title only, or a label you draw yourself; corner radius for cards.
- One row open at a time through a shared
SwipeGroup, which also opens and closes rows from code. - Tap outside to close: a tap anywhere in the group, or on another row, closes the open row instead of going through.
- Rubber banding past the buttons and towards edges without actions.
- Right-to-left: leading and trailing follow the layout direction.
- Accessibility: every action is a VoiceOver custom action on the row, and Reduce Motion shortens the animations.
- Scroll friendly: a drag decides once whether it is horizontal, so vertical scrolling keeps working.
- Tested: the gesture threshold, rubber band, settle and button layout math is pure and covered by Swift Testing.
| API | What it does |
|---|---|
.swipeable(leading:trailing:) |
Adds leading and trailing actions to any view. |
.swipeable(id:leading:trailing:) |
The same, with a stable id so a SwipeGroup can open and close the row from code. |
SwipeAction(_:systemImage:tint:role:action:) |
A button with a title, an optional SF Symbol and a tint (red for destructive, gray otherwise by default). |
SwipeAction(_:tint:role:action:label:) |
A button with a label you draw; the title is still the VoiceOver name. |
SwipeActionRole |
.normal closes the row after the action; .destructive slides the row out and collapses it first. |
SwipeActionBuilder |
The result builder behind the action closures. |
SwipeGroup |
Keeps one row open; open(_:edge:), close(), openRow, isOpen(_:), isAnyRowOpen. |
.swipeGroup(_:) |
Joins the rows inside a container to a group and closes the open row on a tap anywhere in it. |
SwipeOptions |
Style, button width, full swipe edges and threshold, rubber banding, haptics, label style, button shape, corner radius, accessibility and drag distance. .default and .cards presets. |
.swipeOptions(_:) |
Sets the options for every swipeable view inside. |
.swipeStyle(_:), .swipeButtonShape(_:), .swipeFullSwipe(_:) |
Change one option. |
SwipeRevealStyle |
.reveal, .drag, .expand. |
SwipeButtonShape |
.rectangle, .rounded(cornerRadius:spacing:). |
SwipeActionLabelStyle |
.titleAndIcon, .iconOnly, .titleOnly. |
SwipeEdge, SwipeEdges |
.leading and .trailing, as one edge or a set. |
.swipeDebugProgress(_:edge:) |
Freezes a row part of the way open, for previews and screenshots. |
In Xcode, choose File → Add Package Dependencies… and enter:
https://github.com/halilozel1903/SwipeActions
Or add it to Package.swift:
dependencies: [
.package(url: "https://github.com/halilozel1903/SwipeActions", from: "1.0.0"),
],
targets: [
.target(name: "YourApp", dependencies: ["SwipeActions"]),
]The first action of an edge is the one closest to the screen edge, and the one a full swipe runs.
TaskRow(task: task)
.swipeable {
SwipeAction(task.isDone ? "Undo" : "Done", systemImage: "checkmark", tint: .green) {
tasks.toggleDone(task)
}
} trailing: {
SwipeAction("Delete", systemImage: "trash.fill", role: .destructive) {
tasks.delete(task)
}
if task.canPostpone {
SwipeAction("Later", systemImage: "clock.fill", tint: .indigo) {
tasks.postpone(task)
}
}
}Only trailing actions? Use .swipeable(trailing: { … }).
row.swipeStyle(.reveal) // the buttons stay in place under the view (default)
row.swipeStyle(.drag) // the buttons slide in attached to the view's edge
row.swipeStyle(.expand) // the buttons share the uncovered space and grow with itA full swipe runs the edge's first action once the drag passes fullSwipeThreshold of the row's width (60 % by default, and never less than the open width plus half a button). A haptic plays as the finger crosses it, and the edge action expands to fill the space so the person sees what will happen.
When the action has the .destructive role, the row slides out, collapses, and only then runs the action, so removing the item from your data finishes the animation. If the action keeps the item, the row comes back closed.
var options = SwipeOptions()
options.fullSwipeEdges = .trailing // only the trailing edge
options.fullSwipeThreshold = 0.7
options.hapticsEnabled = true
content.swipeOptions(options)@State private var swipeGroup = SwipeGroup()
ScrollView {
LazyVStack {
ForEach(items) { item in
ItemCard(item).swipeable(id: item.id) { … } trailing: { … }
}
}
}
.swipeGroup(swipeGroup)
// Later:
swipeGroup.open(item.id, edge: .trailing) // shows the trailing actions of that row
swipeGroup.close()Starting a swipe on one row closes the open one. While a row is open, a tap on it or on another swipeable row closes it instead of tapping through, and a tap anywhere else in the container closes it too.
content.swipeOptions(.cards)
// or tune it:
content.swipeOptions(SwipeOptions(
style: .reveal,
labelStyle: .iconOnly,
buttonShape: .rounded(cornerRadius: 20, spacing: 10),
cornerRadius: 20
))The row and its buttons are clipped to cornerRadius. Apply a shadow after .swipeable so it isn't clipped.
SwipeAction("Snooze", tint: .purple) {
snooze(mail)
} label: {
VStack {
Image(systemName: "moon.zzz.fill")
Text("1 h").font(.caption2.bold())
}
.foregroundStyle(.white)
}Leading and trailing follow layoutDirection: in Arabic or Hebrew, leading actions are on the right and are uncovered by swiping left. Nothing to configure.
Each action is a VoiceOver custom action on the row (swipe up or down with one finger to pick one, double-tap to run it). By default the row is read as one element; set combinesAccessibilityChildren to false when the row has its own controls that must stay reachable. With Reduce Motion on, the open, close and removal animations are short ease-out curves instead of springs.
#Preview("Full swipe") {
MailCard(mail: .preview)
.swipeable { … } trailing: { … }
.swipeDebugProgress(0.8, edge: .trailing)
}The row is frozen at 80 % of its width towards the trailing edge, with the edge action expanded, and ignores drags.
SwiftUI swipeActions |
SwipeActions | |
|---|---|---|
| Where it works | List rows |
Any view, in ScrollView, LazyVStack or VStack |
| Button look | System rectangles | Rectangles, rounded card buttons, custom labels |
| Reveal styles | One | Reveal, drag, expand |
| One open row, open from code | No | SwipeGroup |
| Full swipe threshold | Fixed | Per edge, adjustable |
Inside a List, SwiftUI's own modifier is usually the better choice.
cd Example
xcodegen generate
open SwipeActionsDemo.xcodeprojRun SwipeActionsDemo on an iPhone simulator. The Inbox tab is a mail-like list of cards in a LazyVStack (switch the reveal style and a right-to-left layout from the toolbar menu); the Tasks tab is a task list in card sections built from plain VStacks. Both use a bundled demo dataset. Launch with -screenshot trailing|leading|fullswipe|cards to open a fixed scene, as CI does for the screenshots above.
- Xcode 26 or later (Swift 6.2 toolchain)
- iOS 17+ (macOS 14+ for running the tests)
Issues and pull requests are welcome. Please run the tests before opening a PR:
swift test
bash scripts/test.sh iOSSwipeActions is available under the MIT license. See LICENSE.



