To edit a filtered SwiftUI list without changing the wrong item, keep one original array and bind each visible row back to it by a stable ID. This macOS example lets you filter, rename, toggle, and remove synthetic labels in your own app.

Why filtering alone does not create editable rows

Swift’s filter(_:) returns a new collection containing the matching elements in their original order. It does not create a writable relationship between that collection and its source. With an array of structures, changing an element in a separately stored filtered array does not update the original array. These conclusions follow from the documented behavior of filter(_:) and Swift’s value semantics for structures.

For example, suppose the original array contains Grid, Tile, and Ring, but a filter displays only Ring. Its visible position is zero; its original position is two. Using the visible position to write into the original array would change Grid. Sorting the displayed results can produce the same mismatch.

A binding supplies the missing read-and-write connection. Apple documents Binding as a connection to data owned elsewhere, and provides init(get:set:) for custom access. Here, both closures locate the original item by ID whenever they run. The filtered collection determines which rows appear; the original array receives edits. See Binding and init(get:set:).

Choose stable identity before building the editor

Each item needs an ID that remains unchanged when its name, inclusion setting, or array position changes. Apple’s Identifiable protocol describes stable entity identity, while ForEach uses identifiable data or an explicit ID to distinguish its generated views. See Identifiable and ForEach.

The example creates each UUID once in a stored let id property. Renaming a label leaves its ID intact. Avoid deriving identity from the editable name or generating a new UUID whenever an ID is read. Also ensure that distinct items have distinct IDs: the lookup deliberately finds the first matching ID, so duplicates would make the write target ambiguous.

Use this decision aid when choosing the data flow:

Interface requirement Binding approach
Display an unchanged collection of values Pass values to read-only rows.
Edit the original collection directly Use SwiftUI’s collection-binding support where appropriate.
Edit results whose positions differ from the source Resolve the original item by stable ID.
Keep an editor open after its item leaves the results Give the editor its own selection and lifetime policy.

The remainder implements the third case. It also defines what happens when a row disappears, so a missing item never becomes an unchecked array subscript.

Build a complete macOS example

Create a macOS app project in Xcode using SwiftUI, and set its deployment target to macOS 13 or later for this example. Replace the generated app and content-view declarations with the following single file. Keep exactly one @main entry point; Apple’s App documentation describes that requirement and the WindowGroup structure used here.

The parent owns the original array in private @State; each child receives an @Binding. This follows Apple’s documented pattern for sharing state with subviews. The sample contains only local synthetic labels and keeps its data in memory.

import SwiftUI
import Foundation

private struct LabelItem: Identifiable, Sendable {
    let id = UUID()
    var name: String
    var isIncluded: Bool
}

@main
struct FilteredLabelsApp: App {
    var body: some Scene {
        WindowGroup {
            LabelEditor()
        }
    }
}

@MainActor
private struct LabelEditor: View {
    @State private var items = [
        LabelItem(name: "Grid", isIncluded: true),
        LabelItem(name: "Tile", isIncluded: false),
        LabelItem(name: "Ring", isIncluded: true)
    ]
    @State private var query = ""
    @State private var includedOnly = false
    @State private var notice = ""

    private var visibleItems: [LabelItem] {
        items.filter { item in
            (!includedOnly || item.isIncluded) &&
            (query.isEmpty ||
             item.name.localizedCaseInsensitiveContains(query))
        }
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            TextField("Filter labels", text: $query)
            Toggle("Included only", isOn: $includedOnly)

            Button("Reverse original order") {
                items.reverse()
            }

            List {
                ForEach(visibleItems) { snapshot in
                    LabelRow(item: itemBinding(for: snapshot)) {
                        items.removeAll { $0.id == snapshot.id }
                    }
                }
            }

            Text("Original array:")
                .font(.headline)
            Text(items.map(\.name).joined(separator: " · "))
            Text(notice)
        }
        .padding()
        .frame(minWidth: 540, minHeight: 360)
    }

    private func itemBinding(
        for snapshot: LabelItem
    ) -> Binding<LabelItem> {
        let id = snapshot.id

        return Binding(
            get: {
                items.first { $0.id == id } ?? snapshot
            },
            set: { updated in
                guard let index = items.firstIndex(where: {
                    $0.id == id
                }) else {
                    notice = "Edit skipped: label was removed."
                    return
                }

                items[index].name = updated.name
                items[index].isIncluded = updated.isIncluded
                notice = ""
            }
        )
    }
}

@MainActor
private struct LabelRow: View {
    @Binding var item: LabelItem
    let remove: () -> Void

    var body: some View {
        HStack {
            TextField("Label name", text: $item.name)
            Toggle("Included", isOn: $item.isIncluded)
            Button("Remove", action: remove)
        }
    }
}

This is a complete app example, rather than a fragment to paste inside an existing view. It has been inspected against the retrieved API documentation, but was not compiled here because this environment has no Swift toolchain or macOS SDK.

How an edit reaches the original array

The computed visibleItems property recalculates membership from the original array and current filter settings. ForEach receives those results and creates one row per matching item. Filtering before constructing rows also avoids putting a conditional zero-or-one-row branch inside each iteration, a pattern discussed in Apple’s ForEach performance guidance.

When a row reads its binding, the getter searches for its ID in the current original array. The snapshot is only a fallback value for an item that has already been removed. While the item exists, the getter returns the current original value rather than the snapshot captured when the row was created.

When the user edits Label name, $item.name projects the item binding down to its writable name property. Apple documents this property projection through Binding’s dynamic-member subscript. The setter then finds the item’s current original index and updates its editable fields, preserving its stored ID.

The lookup uses firstIndex(where:), which returns an optional index and returns nil when there is no match. The code checks that result before subscripting. Because it repeats the lookup for every write, reversing the original array does not leave the setter holding an old index. See Apple’s firstIndex(where:) documentation.

What happens when a row disappears?

There are two different cases. If an edit makes an item fail the filter, the item still exists in the original array. The edit is retained, but the row leaves the results. For example, turning off Included while Included only is enabled hides that row; turning off the filter reveals it again.

If the item has been removed from the original array, a later write through an old binding is rejected. The sample reports Edit skipped: label was removed. It does not insert the snapshot back into the array. Returning a snapshot from the getter and rejecting missing-item writes are explicit policies of this example, not guarantees supplied by SwiftUI.

Live name filtering needs an interaction decision too. Apple documents that a string-bound TextField updates its value continuously during editing. Consequently, a row can disappear as soon as its new name stops matching the query. For uninterrupted renaming, clear the query first, or design a separate editor that commits a draft on an explicit action. See TextField.

Verify the behavior with a short desktop check

The following checks describe expected behavior derived from the sample; they are not reported execution results. Start a fresh run for the initial labels, then perform the steps in order.

Action Expected result
Enter Ring in Filter labels. Only Ring appears.
Rename it to Ring Two. The original-array readout becomes Grid · Tile · Ring Two.
Clear the query and click Reverse original order. The readout becomes Ring Two · Tile · Grid.
Rename Grid to Grid Two. Only that item’s original value changes.
Enable Included only, then turn off Included for Grid Two. That row disappears, but its name remains in the original-array readout.
Disable Included only. Grid Two returns with its toggle off.
Enter Tile, then click its Remove button. Its row and original-array entry both disappear.

If the wrong item changes, inspect whether a displayed offset is being used as an original-array index. If edits disappear after clearing the filter, check whether the row owns a separate @State copy. If a renamed row behaves like a new item, inspect whether its ID depends on editable content. These checks target the three relationships the example keeps explicit: source ownership, write destination, and row identity.

Keep the solution within its macOS and API limits

For short editable labels, the sample’s SwiftUI TextField provides the required interface. A richer document editor may instead need AppKit’s NSTextView, which supports rich text and integrates with text-system components such as NSTextStorage and NSTextLayoutManager. That changes the editing control; the application still needs to decide which original model item receives each edit. See NSTextView.

The supported bridge for an AppKit view in a SwiftUI hierarchy is NSViewRepresentable. Apple documents creating and updating the represented view and using a coordinator to forward interactions. For a future custom editor, preserve the same stable-ID write destination. Let SwiftUI manage the represented view’s layout rather than directly changing its managed frame or bounds. See NSViewRepresentable.

An AppKit extension also has TextKit boundaries: Apple documents that accessing NSTextView.layoutManager can switch the view to compatibility mode using NSLayoutManager. Check the availability and behavior of the specific text APIs before adopting them. The collection-binding solution itself requires no TextKit customization. See the compatibility discussion in NSTextView.

For larger collections, account for lookup work. Apple documents linear complexity for both filter(_:) and firstIndex(where:); repeated per-row searches can therefore become significant as the collection grows. Measure your interface before introducing an ID-to-index cache, and rebuild any such cache whenever structural changes alter the indices. See filter(_:) and firstIndex(where:).

Keep UI access to these computed bindings on the main actor, as the example does. Apple’s binding initializer documentation explains that a computed binding’s permitted access depends on the isolation of its closures. Passing a binding elsewhere does not remove those restrictions. See init(get:set:).

For this filtered label editor, retain one original array, use stable IDs for rows, and resolve the write target at the moment of editing. Then choose disappearance behavior deliberately: a filtered-out item keeps its edit, while a removed item rejects further writes with a visible explanation.

Sources