How to properly use dynamic member bindings in SwiftUI?

913 Views Asked by At

I'm attempting to use SwiftUI's Binding members from @Binding variables (via its support for @dynamicMemberLookup), but even with a simple example I can recreate multiple issues. My best guess is that I'm using it incorrectly, but documentation and examples online would suggest otherwise.

The main issue (reproducible on Catalina, Big Sur, and iPadOS 13 and 14) is deleting an item while the view is open triggers a crash with an index out of range error.

Fatal error: Index out of range: file /AppleInternal/BuildRoot/Library/Caches/com.apple.xbs/Sources/swiftlang/swiftlang-1103.8.25.8/swift/stdlib/public/core/ContiguousArrayBuffer.swift, line 444

The secondary issue occurs in the text field on Catalina, attempting to edit the text hides the left/navigation view. (On Big Sur, editing the text hides the right/detail view, which I assume is a different manifestation of the same issue due to the improvements to navigation views.)

struct Child: Identifiable, Hashable {
    var id = UUID()
    var bar: String = "Text"

    func hash(into hasher: inout Hasher) {
        self.id.hash(into: &hasher)
    }
}

struct ChildView: View {
    let child: Child

    var body: some View {
        Text(child.bar)
    }
}

struct ChildEditor: View {
    @Binding var child: Child

    var body: some View {
        TextField("Text", text: self.$child.bar)
    }
}

struct ContentView: View {
    @State var children: [Child] = []

    func binding(for child: Child) -> Binding<Child> {
        guard let it = children.firstIndex(of: child) else {
            fatalError()
        }
        return $children[it]
    }

    var plusButton: Button<Image> {
        return Button(action: {
            self.children.append(Child())
        }) {
            Image(systemName: "plus")
        }
    }

    func ParentList<Content: View>(_ content: () -> Content) -> some View {
        #if os(macOS)
        return List(content: content)
            .toolbar {
                ToolbarItem {
                    self.plusButton
                }
            }
        // uncomment for 10.15
//        return List {
//            self.plusButton
//            content()
//        }
        #elseif os(iOS)
        return List(content: content)
            .navigationBarItems(trailing: self.plusButton)
        #endif
    }

    var body: some View {
        NavigationView {
            ParentList {
                ForEach(children) { child in
                    NavigationLink(destination: ChildEditor(child: self.binding(for: child))) {
                        ChildView(child: child)
                    }
                }
                .onDelete { offsets in
                    self.children.remove(atOffsets: offsets)
                }
            }
        }
    }
}

My base assumption would be that Binding essentially stores a pointer, so on delete the pointer would become invalid and trigger a crash, and that editing the text field is triggering a view update of the parent view, invalidating the current content (this is backed up by Big Sur sometimes complaining that a state variable was modified during view update, even though it's properly only used to the init of a TextField). However, changing to use a class type and @ObservedObject/@EnvironmentObject (or @StateObject) delays the crash (on Catalina and iPadOS 13/14) to when any other navigation action is taken or has no effect (on Big Sur). Using the tag option in NavigationLink to dismiss the view if deleted also failed.

The first question is: what am I doing wrong? If the answer to that is "Everything", how should one manage an array of data in a top-level view and create bindings to members for nested subviews?

0

There are 0 best solutions below