3

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 unterstand 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.

T. Karter
  • 638
  • 7
  • 25
  • You can start with unwrapping optionals (`children!`) only when you're sure that they exist. Eg. in `deleteItem` in `PuppetView` you don't check it against `nil`. – pawello2222 Jun 02 '20 at 09:25
  • Also you can extract `self.appState.parents[self.parentIndex].children![self.childIndex].puppets` to a variable as well, so your code will be easier to read (and debug). – pawello2222 Jun 02 '20 at 09:26
  • Thank you for the tipps. I updated my post. The issue still occurs and the changes have no effect. – T. Karter Jun 02 '20 at 09:43

2 Answers2

2

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)
        }
    }
}
Paulw11
  • 108,386
  • 14
  • 159
  • 186
  • This is the first time ever I received a copy & paste solution that directly works. So thank you for that. Your answer is solving my issue, so I'll mark it as resolved. But the code is so much different to mine and is in a style that I never saw, so that I need some time to fully understand it. But I will do my best. Regards – T. Karter Jun 02 '20 at 12:06
  • I want to add, this is not just an answer, this is motivating me, moving forward in getting better and better in iOS and shows me, how far I am behind lol Thank you again – T. Karter Jun 02 '20 at 12:10
  • I have one additional question: How can I make `Parent` conform to `Codable` so that I can save the appState to `UserDefaults`? – T. Karter Jun 02 '20 at 14:57
  • Hi T. Your question has been answered. This additional question in the comments should be asked by opening a new question. That way others will be able to find it. Text within comments won't show up in searches with the same prominence as a question title. This also gives the answerer the opportunity to gain further reputation (you as well). – Mozahler Jun 02 '20 at 15:58
  • 1
    Good point. Here you go. https://stackoverflow.com/questions/62156905/how-can-i-make-my-model-conform-to-codable-and-safe-it-locally-on-every-change – T. Karter Jun 02 '20 at 16:38
0

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.

pawello2222
  • 46,897
  • 22
  • 145
  • 209
  • with `let child = appState.parents[parentIndex].children?[childIndex]` I am getting `Function declares an opaque return type, but has no return statements in its body from which to infer an underlying type` – T. Karter Jun 02 '20 at 11:26
  • With not extracting `appState.parents[parentIndex].children?[childIndex]` but adding `appState.parents[parentIndex].children?[childIndex] != nil` I still get the Index out of range. – T. Karter Jun 02 '20 at 11:28
  • Thank you for making my code more readable to get a solution for my issue soon. I updated my original post. – T. Karter Jun 02 '20 at 11:47
  • @T.Karter I updated my answer, so you can use your arrays in the *safe* way. But the other solution is cleaner. I recommend it unless you don't want to have `@ObservedObjects`. – pawello2222 Jun 02 '20 at 12:00
  • Thank you anyway. Everything that improves my code is helpful! – T. Karter Jun 02 '20 at 12:11