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
ListwithForEachenables swipe-to-delete and drag-to-reorder using.onDeleteand.onMove - How
EditButtonactivates edit mode, which reveals delete controls and drag handles simultaneously - How
Sectionorganizes 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
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
@State var fruits: [String]— The mutable data source for the fruits section. Every time this array changes (viaadd,delete, ormove), SwiftUI re-renders theListwith the updated rows and the appropriate insertion/deletion animation.ForEach(fruits, id: \.self)— Uses each fruit string as its own identifier. For simple value types likeString,id: \.selfworks. For model objects, useid: \.idwith a properIdentifiableconformance to avoid re-render bugs when strings are duplicated..onDelete(perform: delete)— Registers thedeletefunction as the swipe-to-delete handler. The function signature(IndexSet) -> Voidmatches what.onDeleteexpects.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.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.EditButton()— A system-provided button that reads and writes the edit mode environment. When tapped, it changes to "Done" and activates editing across allForEachblocks with delete/move handlers. Zero configuration needed.
Common Mistakes
Mistake: Attaching .onDelete to List instead of ForEachList 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 insideEditButton 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
.onDeleteand.onMovemust be attached to aForEachinside aList, not to theListitself.- The delete and move callbacks mutate your
@Statearray — 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 eligibleForEachblocks.
Last updated: June 27, 2026