T. Karter
T. Karter

Reputation: 738

How can I fix "index out of Range" for a multi-dimensional view?

I tried as much as I could before asking the next "Index out of Range" question, because generally I understand why an index out of range issue occurs, but this specific issues makes me crazy:

struct Parent: Identifiable {
    let id = UUID()
    let name: String
    var children: [Child]?
}

struct Child: Identifiable {
    let id = UUID()
    let name: String
    var puppets: [Puppet]?
}

struct Puppet: Identifiable {
    let id = UUID()
    let name: String
}

class AppState: ObservableObject {
    @Published var parents: [Parent]
    init() {
        self.parents = [
            Parent(name: "Foo", children: [Child(name: "bar", puppets: [Puppet(name: "Tom")])]),
            Parent(name: "FooBar", children: [Child(name: "foo", puppets: nil)]),
            Parent(name: "FooBar", children: nil)
        ]
    }
}


struct ContentView: View {
    @EnvironmentObject var appState: AppState
    var body: some View {
        NavigationView {
            VStack {
                List {
                    ForEach (appState.parents.indices, id: \.self) { parentIndex in
                        NavigationLink (destination: ChildrenView(parentIndex: parentIndex).environmentObject(self.appState)) {
                            Text(self.appState.parents[parentIndex].name)
                        }
                    }
                    .onDelete(perform: deleteItem)
                }
                Button(action: {
                    self.appState.parents.append(Parent(name: "Test", children: nil))
                }) {
                    Text("Add")
                }
                .padding(.bottom, 15)
            }
        .navigationBarTitle(Text("Parents"))
        }
    }
    private func deleteItem(at indexSet: IndexSet) {
        self.appState.parents.remove(atOffsets: indexSet)
    }
}


struct ChildrenView: View {
    @EnvironmentObject var appState: AppState
    var parentIndex: Int
    var body: some View {
        let children = appState.parents[parentIndex].children
        return VStack {
            List {
                if (children?.indices != nil) {
                    ForEach (children!.indices, id: \.self) { childIndex in
                        NavigationLink (destination: PuppetsView(parentIndex: self.parentIndex, childIndex: childIndex).environmentObject(self.appState)) {
                            Text(children![childIndex].name)
                        }
                    }
                    .onDelete(perform: deleteItem)
                }
            }
            Button(action: {
                var children = self.appState.parents[self.parentIndex].children
                if (children != nil) {
                    children?.append(Child(name: "Teest"))
                } else {
                    children = [Child(name: "Teest")]
                }
            }) {
                Text("Add")
            }
            .padding(.bottom, 15)
        }
        .navigationBarTitle(Text("Children"))
    }
    private func deleteItem(at indexSet: IndexSet) {
        if (self.appState.parents[self.parentIndex].children != nil) {
            self.appState.parents[self.parentIndex].children!.remove(atOffsets: indexSet)
        }
    }
}


struct PuppetsView: View {
    @EnvironmentObject var appState: AppState
    var parentIndex: Int
    var childIndex: Int
    var body: some View {
        let child = appState.parents[parentIndex].children?[childIndex]
        return VStack {
            List {
                if (child != nil && child!.puppets?.indices != nil) {
                    ForEach (child!.puppets!.indices, id: \.self) { puppetIndex in
                        Text(self.appState.parents[self.parentIndex].children![self.childIndex].puppets![puppetIndex].name)
                    }
                    .onDelete(perform: deleteItem)
                }
            }
            Button(action: {
                var puppets = self.appState.parents[self.parentIndex].children![self.childIndex].puppets
                if (puppets != nil) {
                   puppets!.append(Puppet(name: "Teest"))
                } else {
                   puppets = [Puppet(name: "Teest")]
                }
            }) {
                Text("Add")
            }
            .padding(.bottom, 15)
        }
        .navigationBarTitle(Text("Puppets"))
    }
    private func deleteItem(at indexSet: IndexSet) {
        if (self.appState.parents[self.parentIndex].children != nil) {
            self.appState.parents[self.parentIndex].children![self.childIndex].puppets!.remove(atOffsets: indexSet)
        }
    }
}

I can remove both children of Foo and FooBar without issues, but when I remove the Puppet of child bar first, then the app crashes like shown in the comments.

I understand that the childIndex doesn't exist anymore, but I don't understand why the view gets built again when there is no child with puppets.

Upvotes: 3

Views: 343

Answers (2)

pawello2222
pawello2222

Reputation: 54516

The problem with your optional chaining is that this line produces the result of type Child and not Child?:

appState.parents[parentIndex].children?[childIndex]

And if it's not an optional you can't call puppets on children?[childIndex] without checking if childIndex is valid:

// this will crash when childIndex is out of range
appState.parents[parentIndex].children?[childIndex].puppets?.indices

I recommend to use safeIndex subscript for accessing possible empty elements:

var body: some View {

    let child = appState.parents[safeIndex: parentIndex]?.children?[safeIndex: childIndex]
    return VStack {
        List {
            if (child != nil && child!.puppets?.indices != nil) {
                ForEach ((appState.parents[parentIndex].children?[childIndex].puppets!.indices)!, id: \.self) { puppetIndex in
                    Text(self.appState.parents[self.parentIndex].children![self.childIndex].puppets![puppetIndex].name)
                }
                .onDelete(perform: deleteItem)
            }
        }
    ...
}

To do this you would need an Array extension which allows to access array elements in a safe way (ie. return nil instead of throwing an error):

extension Array {
    public subscript(safeIndex index: Int) -> Element? {
        guard index >= 0, index < endIndex else {
            return nil
        }

        return self[index]
    }
}

Note: You'd need to do the same for the ParentView, so in overall Paulw11's answer is cleaner.

Upvotes: 0

Paulw11
Paulw11

Reputation: 114875

All the referencing of array indices looks pretty awful to me. Using array indices also requires that you pass the various objects down to the subviews.

To address this I started by changing your models - Make them classes rather than structs so you can make them @ObservableObject. They also need to be Hashable and Equatable.

I also added add and remove functions to the model objects so that you don't need to worry about indices when adding/removing children/puppets. The remove methods use an array extension that removes an Identifiable object without needing to know the index.

Finally, I changed the children and puppets arrays to be non-optional. There is little semantic difference between a nil optional and an empty non-optional array, but the latter is much easier to deal with.

class Parent: ObservableObject, Hashable {

    static func == (lhs: Parent, rhs: Parent) -> Bool {
        lhs.id == rhs.id
    }

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

    let id = UUID()
    let name: String
    @Published var children: [Child]

    init(name: String, children: [Child]? = nil) {
        self.name = name
        self.children = children ?? []
    }

    func remove(child: Child) {
        self.children.remove(child)
    }

    func add(child: Child) {
        self.children.append(child)
    }
}

class Child: ObservableObject, Identifiable, Hashable {
    static func == (lhs: Child, rhs: Child) -> Bool {
        return lhs.id == rhs.id
    }

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

    let id = UUID()
    let name: String
    @Published var puppets: [Puppet]

    init(name: String, puppets:[Puppet]? = nil) {
        self.name = name
        self.puppets = puppets ?? []
    }

    func remove(puppet: Puppet) {
        self.puppets.remove(puppet)
    }

    func add(puppet: Puppet) {
        self.puppets.append(puppet)
    }
}

struct Puppet: Identifiable, Hashable {
    let id = UUID()
    let name: String
}

class AppState: ObservableObject {
    @Published var parents: [Parent]
    init() {
        self.parents = [
            Parent(name: "Foo", children: [Child(name: "bar", puppets: [Puppet(name: "Tom")])]),
            Parent(name: "FooBar", children: [Child(name: "foo", puppets: nil)])
        ]
    }
}

extension Array where Element: Identifiable {
    mutating func remove(_ object: Element) {
        if let index = self.firstIndex(where: { $0.id == object.id}) {
            self.remove(at: index)
        }
    }
}

Having sorted out the model, the views then only need to know about their specific item:

struct ContentView: View {
    @EnvironmentObject var appState: AppState
    var body: some View {
        NavigationView {
            VStack {
                List {
                    ForEach (appState.parents, id: \.self) {  parent in
                        NavigationLink (destination: ChildrenView(parent: parent)) {
                            Text(parent.name)
                        }
                    }
                    .onDelete(perform: deleteItem)
                }
                Button(action: {
                    self.appState.parents.append(Parent(name: "Test", children: nil))
                }) {
                    Text("Add")
                }
                .padding(.bottom, 15)
            }
            .navigationBarTitle(Text("Parents"))
        }
    }
    private func deleteItem(at indexSet: IndexSet) {
        self.appState.parents.remove(atOffsets: indexSet)
    }
}

struct ChildrenView: View {
    @ObservedObject var parent: Parent
    var body: some View {
        VStack {
            List {
                ForEach (self.parent.children, id: \.self) { child in
                    NavigationLink (destination: PuppetsView(child:child)) {
                        Text(child.name)
                    }
                }
                .onDelete(perform: deleteItem)
            }
            Button(action: {
                self.parent.add(child: Child(name: "Test"))
            }) {
                Text("Add")
            }
            .padding(.bottom, 15)
        }
        .navigationBarTitle(Text("Children"))
    }

    private func deleteItem(at indexSet: IndexSet) {
        let children = Array(indexSet).map { self.parent.children[$0]}
        for child in children {
            self.parent.remove(child: child)
        }
    }
}

struct PuppetsView: View {
    @ObservedObject var child: Child
    var body: some View {
        VStack {
            List {
                ForEach (child.puppets, id: \.self) { puppet in
                    Text(puppet.name)
                }
                .onDelete(perform: deleteItem)
            }
            Button(action: {
                self.child.add(puppet:Puppet(name: "Test"))
            })
            {
                Text("Add")
            }
            .padding(.bottom, 15)
        }
        .navigationBarTitle(Text("Puppets"))
    }

    func deleteItem(at indexSet: IndexSet) {
        let puppets = Array(indexSet).map { self.child.puppets[$0] }
        for puppet in puppets {
            self.child.remove(puppet:puppet)
        }
    }
}

Upvotes: 2

Related Questions