Skip to content

Add custom List Swipe Actions in SwiftUI | SwiftUI Bootcamp #58

Swipe actions are one of the most powerful interaction patterns in iOS — think swiping a Mail message to archive it, or swiping a Reminder to complete it. iOS 15 added native swipeActions support to SwiftUI, replacing the limited .onDelete with fully customizable buttons on both trailing and leading edges. After this lesson you'll know how to add multiple swipe actions per edge, style them with tints, and control whether a full swipe triggers the first action.

What You'll Learn

  • How to use .swipeActions(edge:allowsFullSwipe:) to add custom buttons to List rows
  • How to add multiple actions on both the trailing (right) and leading (left) edges of a row
  • How .tint() on a swipe action button sets its background color
  • The difference between allowsFullSwipe: true and allowsFullSwipe: false

Mental Model

Think of swipe actions like a junk drawer behind a sliding panel on each table row. The trailing edge (swipe right-to-left) reveals buttons from right to left — like opening a drawer from the right side. The leading edge (swipe left-to-right) reveals buttons from left to right — like opening a drawer from the left. Each drawer can have multiple items (buttons), each with its own color and label.

allowsFullSwipe: true on the trailing edge is like a "quick throw" gesture — if the user swipes far enough (past a threshold), the first button's action fires automatically without tapping it. This is the Mail "Archive on full swipe" behavior. allowsFullSwipe: false means the user always has to explicitly tap a button — the row never auto-triggers.

Detailed Explanation

.swipeActions(edge:allowsFullSwipe:content:) is a modifier added to iOS 15. It's applied to each row view inside a ForEach (not to the List itself). The edge parameter is .trailing (right side, revealed by swiping left) or .leading (left side, revealed by swiping right). The content closure contains one or more Button views that appear as swipe action buttons.

The buttons appear in the order declared, from the edge inward. For .trailing, the first declared button is closest to the right edge; for .leading, the first button is closest to the left edge. Each button's background color is set with .tint(Color) — this is different from .foregroundColor, which would only change the label text color. Without .tint(), the button uses the system accent color.

allowsFullSwipe: true enables the "full swipe" gesture where the user swipes past a threshold and the row acts as though the first button was tapped. This is a powerful affordance but also potentially destructive if the first action is irreversible. Mail uses it for Archive (reversible); consider whether your first action is safe enough to trigger accidentally.

You can apply .swipeActions to the same row view multiple times — once for .trailing and once for .leading. This is how the sample gets actions on both sides of the same row. The ForEach loop applies these independently to every row in the list.

The commented-out .onDelete(perform: delete) shows the older API. .onDelete is simpler (single swipe-to-delete on trailing) but can't be combined with .swipeActions on the same ForEach. Choose one or the other; if you need .swipeActions, implement delete as one of your buttons.

Code Structure

ListSwipeActionsBootcamp.swift presents a list of four fruit names. Each row has three trailing swipe actions (Archive, Save, Junk) with different tint colors, and one leading swipe action (Share). An empty delete function is included to show how .onDelete would be connected.

Complete Code

ListSwipeActionsBootcamp.swift

swift
import SwiftUI

struct ListSwipeActionsBootcamp: View {
    
    @State var fruits: [String] = [
        "apple", "orange", "banana", "peach"
    ]
    
    var body: some View {
        List {
            ForEach(fruits, id: \.self) {
                Text($0.capitalized) // $0 is the current element in the ForEach shorthand; .capitalized capitalizes the first letter
                    .swipeActions(edge: .trailing, allowsFullSwipe: true) { // trailing = swipe left to reveal; full swipe triggers Archive
                        Button("Archive") {
                            // implement archive logic here
                        }
                        .tint(.green) // background color for the Archive button
                        Button("Save") {
                            // implement save logic here
                        }
                        .tint(.blue) // background color for the Save button
                        Button("Junk") {
                            // implement junk/delete logic here
                        }
                        .tint(.black) // background color for the Junk button
                    }
                    .swipeActions(edge: .leading, allowsFullSwipe: false) { // leading = swipe right to reveal; no full-swipe trigger
                        Button("Share") {
                            // implement share logic here
                        }
                        .tint(.yellow) // background color for the Share button
                    }
            }
            //.onDelete(perform: delete) // older API: adds a single "Delete" swipe on trailing edge; mutually exclusive with swipeActions
        }
    }
    
    func delete(indexSet: IndexSet) {
        // implement deletion from the fruits array here
        // e.g.: fruits.remove(atOffsets: indexSet)
    }
}

struct ListSwipeActionsBootcamp_Previews: PreviewProvider {
    static var previews: some View {
        ListSwipeActionsBootcamp()
    }
}

Code Walkthrough

  1. @State var fruits: [String] — A local array of fruit names. Swipe actions in a real app would typically call view model methods to update a backing data store. Here the array is state directly in the view for simplicity.

  2. ForEach(fruits, id: \.self) — Iterates over the strings, using each string as its own identity (id: \.self). For mutable lists where items might be deleted, using a stable unique ID (like a model's id) is safer — deleting with id: \.self can cause index mismatches if duplicate values exist.

  3. Text($0.capitalized)$0 is Swift's shorthand for the first closure argument when the parameter is omitted. .capitalized capitalizes the first letter of the fruit name for cleaner display. This is equivalent to { fruit in Text(fruit.capitalized) }.

  4. .swipeActions(edge: .trailing, allowsFullSwipe: true) — Adds three buttons to the row's right-side swipe. allowsFullSwipe: true means swiping all the way to the left triggers "Archive" (the first declared button) automatically. The buttons appear left-to-right from the edge: Archive is rightmost (closest to edge), then Save, then Junk.

  5. .tint(.green) on the Archive button — Sets the background fill color for this specific swipe action tile. .tint() applied here colors the action button's entire background, not just the text. The system automatically adjusts text color (white or black) for legibility against the tint.

  6. .swipeActions(edge: .leading, allowsFullSwipe: false) — Adds one button to the left-side swipe. allowsFullSwipe: false means the user must explicitly tap "Share" — a full right-to-left swipe doesn't auto-trigger it. This is appropriate for Share since it opens a sheet, which shouldn't happen accidentally.

  7. Commented-out .onDelete — Cannot be combined with .swipeActions on the same ForEach. If you need a delete action, add it as a .tint(.red) button inside .swipeActions. .onDelete adds the standard red "Delete" button automatically but is less flexible.

Common Mistakes

Mistake: Applying .swipeActions to the List instead of the row views inside ForEach
.swipeActions must be applied to the individual row view (the direct child of ForEach), not to the List or ForEach itself. Applying it to the wrong level produces no visible swipe actions and no error.

Mistake: Using allowsFullSwipe: true on a destructive (non-reversible) first action
If the first action in your trailing .swipeActions is "Delete" and allowsFullSwipe: true, a careless over-swipe permanently deletes the item. Reserve full-swipe for reversible actions like Archive or Mark as Read. For destructive actions, always require an explicit tap.

Mistake: Trying to combine .onDelete and .swipeActions on the same ForEach
.onDelete and .swipeActions conflict — you can't use both on the same ForEach. If you need swipe-to-delete functionality alongside other custom actions, implement delete as a button inside .swipeActions(edge: .trailing) with .tint(.red) and call fruits.remove(atOffsets: ...) inside the button action.

Key Takeaways

  • .swipeActions(edge:allowsFullSwipe:) attaches to individual row views inside a ForEach, not to the List — add it directly to the row content view.
  • Use .tint() on each Button inside .swipeActions to set the action's background color; add actions on both .trailing and .leading edges by chaining two .swipeActions modifiers.
  • Set allowsFullSwipe: true only for safe, reversible actions (Archive, Mark as Read) — a full swipe fires the first action automatically, so it should never be destructive without confirmation.

Last updated: June 27, 2026

Released under the MIT License.