How to implement multi-select in SwiftUI
Pass a Binding<Set<Item.ID>> to List and toggle EditMode to enable SwiftUI's built-in multi-select checkboxes — no custom row tap logic required.
struct Item: Identifiable { let id = UUID(); var title: String }
struct MultiSelectView: View {
@State private var items = [Item(title: "Alpha"), Item(title: "Beta")]
@State private var selection = Set<UUID>()
@Environment(\.editMode) private var editMode
var body: some View {
List(items, selection: $selection) { item in
Text(item.title)
}
.toolbar {
EditButton()
}
}
}
Full implementation
The complete example adds a toolbar with an EditButton (which manages EditMode automatically), a live selection count badge, and a "Delete selected" button that only becomes active when rows are chosen. The selection set is typed to the model's ID — a UUID here — so SwiftUI can match rows without you writing any tap handlers. Accessibility labels on each row ensure VoiceOver announces checked/unchecked state correctly.
import SwiftUI
struct Fruit: Identifiable {
let id = UUID()
var name: String
var emoji: String
}
struct MultiSelectFruitView: View {
@State private var fruits: [Fruit] = [
Fruit(name: "Apple", emoji: "🍎"),
Fruit(name: "Banana", emoji: "🍌"),
Fruit(name: "Cherry", emoji: "🍒"),
Fruit(name: "Date", emoji: "🌴"),
Fruit(name: "Elderberry", emoji: "🫐"),
Fruit(name: "Fig", emoji: "🍇"),
]
// Selection is a Set of the model's ID type (UUID)
@State private var selection = Set<UUID>()
@State private var showDeleteConfirm = false
var body: some View {
NavigationStack {
List(fruits, selection: $selection) { fruit in
Label {
Text(fruit.name)
.font(.body)
} icon: {
Text(fruit.emoji)
.font(.title3)
}
.accessibilityLabel("\(fruit.name), \(selection.contains(fruit.id) ? "selected" : "not selected")")
}
.navigationTitle("Fruits")
.toolbar {
ToolbarItem(placement: .topBarLeading) {
EditButton()
}
ToolbarItem(placement: .topBarTrailing) {
Button(role: .destructive) {
showDeleteConfirm = true
} label: {
Label("Delete", systemImage: "trash")
}
.disabled(selection.isEmpty)
.confirmationDialog(
"Delete \(selection.count) item\(selection.count == 1 ? "" : "s")?",
isPresented: $showDeleteConfirm,
titleVisibility: .visible
) {
Button("Delete", role: .destructive) {
deleteSelected()
}
}
}
}
.safeAreaInset(edge: .bottom) {
if !selection.isEmpty {
selectionBanner
}
}
}
}
// MARK: - Selection banner
private var selectionBanner: some View {
Text("\(selection.count) selected")
.font(.footnote.weight(.semibold))
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(.thinMaterial, in: Capsule())
.padding(.bottom, 12)
.transition(.move(edge: .bottom).combined(with: .opacity))
.animation(.spring(response: 0.3), value: selection.count)
}
// MARK: - Actions
private func deleteSelected() {
withAnimation {
fruits.removeAll { selection.contains($0.id) }
selection.removeAll()
}
}
}
#Preview {
MultiSelectFruitView()
}
How it works
-
Selection binding on List —
List(fruits, selection: $selection)tells SwiftUI to track which rows are checked. SwiftUI compares each row'sidagainst theSet; rows whose ID is present render with a filled checkmark. No customonTapGestureneeded. -
EditButton manages EditMode automatically —
EditButton()in the toolbar toggles the environment's\.editModevalue between.activeand.inactive. Checkboxes are only visible and tappable whileeditMode == .active. -
Disabling the Delete button when nothing is selected —
.disabled(selection.isEmpty)ensures the destructive action is unreachable until at least one row is checked, preventing accidental empty-delete calls. -
Animated selection banner — The
safeAreaInset(edge: .bottom)banner uses.animation(.spring, value: selection.count)so it smoothly slides in and out without affecting the list's own scroll area. -
Accessibility label on rows — The custom
.accessibilityLabelappends "selected" or "not selected" so VoiceOver users get the same feedback as sighted users seeing the checkmark.
Variants
Programmatic EditMode (no EditButton)
When you need to enter multi-select mode in response to a long-press or custom gesture rather than the system toolbar button, inject editMode manually via the environment.
struct ProgrammaticMultiSelect: View {
@State private var items = ["Inbox", "Drafts", "Sent", "Trash"]
@State private var selection = Set<String>()
@State private var editMode: EditMode = .inactive
var body: some View {
List(items, id: \.self, selection: $selection) { item in
Text(item)
}
// Inject editMode into the environment manually
.environment(\.editMode, $editMode)
.onLongPressGesture {
withAnimation { editMode = .active }
}
.toolbar {
if editMode == .active {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") {
withAnimation { editMode = .inactive }
}
}
}
}
}
}
Select All / Deselect All
Add a "Select All" button that populates the selection set in one tap:
selection = Set(fruits.map(\.id))
And "Deselect All" with:
selection.removeAll()
Toggle between them in a single toolbar button by checking whether selection.count == fruits.count.
Common pitfalls
-
⚠️ iOS version: The
List(selection:)initializer for multi-select requires iOS 16+, but the#Previewmacro andconfirmationDialogwithtitleVisibilityrequire iOS 15.4+. All three are available on iOS 17 without version checks. -
⚠️ Selection persists across EditMode toggles: When the user taps "Done" to exit edit mode,
selectionis not automatically cleared by SwiftUI. Callselection.removeAll()when exiting edit mode if a clean slate is expected. -
⚠️ Single-select vs. multi-select confusion: A
Listbound to a single optional value (@State private var selection: UUID?) enables single-select navigation mode, not checkboxes. You must use aSetto get multi-select checkboxes. -
⚠️ Performance with large lists: Because every row re-evaluates
selection.contains(id)on each selection change, prefer value-type models (struct) and avoid storing large blobs inside each item to keep diffing fast.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement multi-select in SwiftUI for iOS 17+. Use List with a Set<ID> selection binding and EditMode. Include a toolbar EditButton, a "Delete selected" button that is disabled when nothing is selected, and an animated selection count banner using safeAreaInset. Make it accessible (VoiceOver labels for selected state). Add a #Preview with realistic sample data.
This prompt fits naturally into the Build phase of a Soarias ship cycle — drop it in after your screens are scaffolded and data models are defined so Claude Code generates selection logic that compiles against your actual types.
Related
FAQ
Does this work on iOS 16?
Yes — List(selection:) with a Set binding was introduced in iOS 16. The #Preview macro requires Xcode 15+ to compile but the runtime behaviour works fine on iOS 16 devices. Just replace NavigationStack with NavigationView if you target below iOS 16.
Can I restrict the maximum number of selected items?
SwiftUI doesn't offer a built-in cap, but you can enforce one with .onChange(of: selection): if selection.count exceeds your limit, remove the most recently added ID. Since the Set doesn't preserve insertion order you'll need to maintain a separate @State private var ordered: [UUID] = [] array in parallel to know which ID to drop.
What's the UIKit equivalent?
In UIKit you'd set tableView.allowsMultipleSelectionDuringEditing = true, call setEditing(_:animated:) to enter edit mode, then collect selected rows from tableView.indexPathsForSelectedRows. The SwiftUI approach eliminates the delegate callbacks and index-path bookkeeping — the Set is your single source of truth.
Last reviewed: 2026-05-11 by the Soarias team.