Skip to content

Add, edit, move, and delete items in a List in SwiftUI | SwiftUI Bootcamp #31

Building a live grocery list, a task manager, or any editable collection requires adding, reordering, and deleting rows. SwiftUI's List makes all of this declarative: .onDelete, .onMove, and EditButton are first-class APIs that handle the gestures, animations, and state updates for you.

What You'll Learn

  • How List with ForEach enables swipe-to-delete and drag-to-reorder using .onDelete and .onMove
  • How EditButton activates edit mode, which reveals delete controls and drag handles simultaneously
  • How Section organizes list content with styled headers and how to add items programmatically

Mental Model

Think of List as a smart table that already knows how to handle common interactions — it just needs you to provide the data array and the callbacks for what to do when the user acts on a row. Swiping a row left to delete it is built in; you only tell SwiftUI which indices to remove from your array (fruits.remove(atOffsets:)). Dragging a row is built in too; you tell SwiftUI how to reorder the array (fruits.move(fromOffsets:toOffset:)). SwiftUI handles every animation and gesture. Your job is just to update the data model correctly.

The EditButton() is like the "Edit" button in Apple's own Mail or Notes apps — it switches the list into edit mode, showing delete circles on the left and drag handles on the right. All you do is place the button in the navigation bar; SwiftUI manages the mode state automatically.

Detailed Explanation

List in SwiftUI renders its content as a vertically scrolling table. When you use ForEach inside a List, each item in the ForEach corresponds to a row. ForEach is required (instead of just List(fruits, id:)) when you want to attach .onDelete or .onMove — these modifiers must be on a ForEach, not on List directly.

.onDelete(perform:) takes a closure that receives an IndexSet — the indices of the rows the user swiped to delete. You pass this set to Array.remove(atOffsets:) on your data array. SwiftUI automatically removes the rows with an animation after the closure runs.

.onMove(perform:) takes a closure that receives an IndexSet (source rows) and an Int (the new position). You pass these to Array.move(fromOffsets:toOffset:). SwiftUI shows the drag handle and handles the visual reordering; you just update the array.

Section(header:) wraps a group of rows with an optional header view. Sections are a purely visual organization tool — they don't affect the data model. The header can be any SwiftUI view, including a styled HStack with an icon.

EditButton() is a pre-built SwiftUI button that toggles the list's edit mode environment value. When active, all ForEach blocks inside the List that have .onDelete or .onMove show their respective controls. No state management required.

Code Structure

ListBootcamp.swift manages two separate string arrays (fruits and veggies) as @State. The fruits section has delete, move, and custom styling. The veggies section is a simple read-only list. The navigation bar includes EditButton (leading) and a custom "Add" button (trailing). The delete, move, and add functions are extracted to keep body clean.

Complete Code

ListBootcamp.swift

swift
import SwiftUI

struct ListBootcamp: View {
    
    @State var fruits: [String] = [
        "apple", "orange", "banana", "peach"
    ]
    @State var veggies: [String] = [
        "tomato", "potato", "carrot"
    ]
    
    var body: some View {
        NavigationView {
            List {
                Section(
                    header:
                        HStack { // custom header view with text and an icon
                            Text("Fruits")
                            Image(systemName: "flame.fill")
                        }
                        .font(.headline)
                        .foregroundColor(.orange)
                ) {
                    ForEach(fruits, id: \.self) { fruit in // id: \.self uses the string value as the identity
                        Text(fruit.capitalized)
                            .font(.caption)
                            .foregroundColor(.white)
                            .padding(.vertical)
                    }
                    .onDelete(perform: delete)  // enables swipe-to-delete; calls delete(indexSet:)
                    .onMove(perform: move)      // enables drag-to-reorder; calls move(indices:newOffset:)
                    .listRowBackground(Color.blue) // custom row background color
                }
                
                Section(header: Text("Veggies")) {
                    ForEach(veggies, id: \.self) { veggies in
                        Text(veggies.capitalized)
                    }
                    // no .onDelete or .onMove — this section is read-only
                }
            }
            .accentColor(.purple)
            //.listStyle(SidebarListStyle())
            .navigationTitle("Grocery List")
            .navigationBarItems(leading: EditButton(), trailing: addButton) // EditButton handles edit mode automatically
        }
        .accentColor(.red) // tints the outer NavigationView's interactive elements
    }
    
    var addButton: some View {
        Button("Add", action: {
            add()
        })
    }
    
    func delete(indexSet: IndexSet) {
        fruits.remove(atOffsets: indexSet) // removes the swiped rows from the array; List updates automatically
    }
    
    func move(indices: IndexSet, newOffset: Int) {
        fruits.move(fromOffsets: indices, toOffset: newOffset) // reorders the array; List updates automatically
    }
    
    func add() {
        fruits.append("Coconut") // adds a new item; the List re-renders with the new row
    }
}

struct ListBootcamp_Previews: PreviewProvider {
    static var previews: some View {
        ListBootcamp()
    }
}

Code Walkthrough

  1. @State var fruits: [String] — The mutable data source for the fruits section. Every time this array changes (via add, delete, or move), SwiftUI re-renders the List with the updated rows and the appropriate insertion/deletion animation.

  2. ForEach(fruits, id: \.self) — Uses each fruit string as its own identifier. For simple value types like String, id: \.self works. For model objects, use id: \.id with a proper Identifiable conformance to avoid re-render bugs when strings are duplicated.

  3. .onDelete(perform: delete) — Registers the delete function as the swipe-to-delete handler. The function signature (IndexSet) -> Void matches what .onDelete expects.

  4. func delete(indexSet: IndexSet)Array.remove(atOffsets:) is a Foundation extension that removes elements at the given indices in one step. This is the idiomatic way to handle deletion — don't manually iterate the index set.

  5. func move(indices: IndexSet, newOffset: Int)Array.move(fromOffsets:toOffset:) handles the reorder. SwiftUI manages the visual drag animation; your function just mutates the array model.

  6. EditButton() — A system-provided button that reads and writes the edit mode environment. When tapped, it changes to "Done" and activates editing across all ForEach blocks with delete/move handlers. Zero configuration needed.

Common Mistakes

Mistake: Attaching .onDelete to List instead of ForEach
List itself does not accept .onDelete. The modifier must be on the ForEach inside the list. If you're not using ForEach (using List(items) shorthand), you need to convert to List { ForEach(items) { ... }.onDelete { ... } }.

Mistake: Using id: \.self with non-unique values
If your array has duplicate strings (e.g., ["apple", "apple"]), SwiftUI can't distinguish the two rows by identity. Use a UUID or any unique field as the id to avoid incorrect row animations and state mixing.

Mistake: Forgetting that EditButton only activates edit mode for the list it's inside
EditButton reads the environment. If it's placed in a navigation bar that wraps a List, it activates edit mode for that list. If you place it elsewhere (e.g., in a button outside the List), you may need to manage @Environment(\.editMode) manually.

Key Takeaways

  • .onDelete and .onMove must be attached to a ForEach inside a List, not to the List itself.
  • The delete and move callbacks mutate your @State array — SwiftUI automatically animates the row changes in response.
  • EditButton() is a zero-configuration component that activates and deactivates edit mode for the entire list, revealing delete and reorder controls across all eligible ForEach blocks.

Last updated: June 27, 2026

Released under the MIT License.