UPGRADE YOUR SKILLS: Learn advanced Swift and SwiftUI on Hacking with Swift+! >>

EnvironmentObject: Passing Array Elements to SubView (and .onDelete())

Forums > SwiftUI

I struggle with @EnvironmentObject and passing Array-Elements to a SubView. For example:

struct Model: ObservableObject {
  @Published var items = [Item]
}

struct Item: Identifiable {
  var id = UUID()
  var title: String
}

struct ContentView(): View {
  @EnvironmentObject var model: Model
  var body: some View {
    NavigationView {
      List {
        ForEach(model.items) {item in
          NavigationLink(destination: SubView(item: item).environmentObject(model)) {
            //Some TextView
          }
        }.onDelete(perform: deleteItem)
      }
    }
  }

  func deleteItem(indexSet: IndexSet) {
    self.model.items.remove(atOffsets: indexSet)
  }
}

struct SubView(): View {
  @EnvironmentObject var model: Model
  var item: Item
  var index: Int {return model.items.firstIndex(where: {$0.id == item.id})!}

  var body: some View {
    TextField("Title", text: $model.items[index])
}

Here are my questions:

  1. Every time I try to swipe-delete an item I get an error message on the line in SubView() where I calculate the index: Thread 1: Fatal error: Unexpectedly found nil while unwrapping an Optional value
  2. Is this really the right way to pass an array-Element of an array that is part of my model to a SubView? Why isn't there a way to create a @Binding? This would make things really easy, because I could get rid of index-evaluation in the SubView.
  3. When do I need to write .environmentObject(model)? Is this every time I change the View? What is the difference between changing a view by NavigationLink and .sheet(...)?
  4. Is it ok to make structs always conform to Identifiable? Why should I then need the ForEach(..., id: \.self)?

I hope my problems are understandable. I really tried hours of solving especially the .onDelete-problem. I also tried to work with .enumerated() and .indices on the array in the ForEach(), but it didn't work. By the way: XCodes error-messages are anything but helpful.

Nico

2      

hi,

i've been following this variation of the same thread for a long time, and it took some time to get my head around the deletion situation as well. so i used the following (very slight) modification of your code, since what you posted was not making sense in XCode 11.5 (perhaps it does in XCode 12).

class Model: ObservableObject {
    @Published var items = [Item]()

    init(titles: [String]) {
        items = titles.map({ Item(title: $0) })
    }

    func updateTitle(for item: Item, to newTitle: String) {
        if let index = items.firstIndex(where: { $0.id == item.id }) {
            items[index].title = newTitle
        }

    }
}

struct Item: Identifiable {
    var id = UUID()
    var title: String

    init(title: String) {
        self.title = title
    }
}

struct ContentView: View {
    @ObservedObject var model = Model(titles: ["Larry", "Moe", "Curly"])
    var body: some View {
        NavigationView {
            List {
                ForEach(model.items) {item in
                    NavigationLink(destination: SubView(item: item).environmentObject(self.model)) {
                        Text(item.title)
                    }
                }
                .onDelete(perform: deleteItem)
            }
        }
    }

    func deleteItem(indexSet: IndexSet) {
        self.model.items.remove(atOffsets: indexSet)
    }
}

struct SubView: View {
    var item: Item
    @EnvironmentObject var model: Model
    @State private var textFieldContents = ""

    var body: some View {
        Form {
            TextField("Title", text: $textFieldContents, onEditingChanged: { _ in
                self.model.updateTitle(for: self.item, to: self.textFieldContents)
            })
        }
        .onAppear(perform: loadItemText)
    }

    func loadItemText() {
        textFieldContents = item.title
    }

}

this should work fine for you as i think you want it to work. note that the loadItemText() function is not called until the subview is actually drawn on screen, at which time it sets up its local @State variable for the text to edit. so some pseudo-answers to your questions.

Every time I try to swipe-delete an item I get an error message on the line in SubView() where I calculate the index: Thread 1: Fatal error: Unexpectedly found nil while unwrapping an Optional value

this might be the hardest thing to explain, and i am not sure that what i'll say here makes real sense. the ContentView is "on screen" when you delete something; when the @Published property wrapper triggers upon a deletion, SwiftUI tries to compare what it has with what it should be. what it has is 3 subviews (in my example) which have probably already been destroyed, so it reinstantiates the subviews without drawing (pre-deletion) to compare what it had with what will be shown next (post-deletion).

the problem is that when it re-instantiates the View structs it had (i think it uses the body property to figure out what to draw, but never really draws them, so never calls the .onAppear modifier), unfortunately, one of them won't be able to recreate the index because you just deleted an item from the model previously referred to, so firstIndex will crash.

this might be a tortured explanation -- serious readers, please jump in here -- but there's an easy way around it in the code i showed above. instantiation of a Subview struct will never try to compute an index, or use the item directly, until the body property is called for view is first rendered onscreen.

Is this really the right way to pass an array-Element of an array that is part of my model to a SubView? Why isn't there a way to create a @Binding? This would make things really easy, because I could get rid of index-evaluation in the SubView.

the code above bypasses this problem; and, frankly, you don't want a @Binding: the deletion problem will only be worse. trust me: i've tried it.

When do I need to write .environmentObject(model)? Is this every time I change the View? What is the difference between changing a view by NavigationLink and .sheet(...)

you could easily pass the model as an argument.

as for NavigationLink and .sheet, they are different presentation styles (one "pushes" something on to the navigation stack with a BackButton, while the other does essentially a modal presentation).

Is it ok to make structs always conform to Identifiable? Why should I then need the ForEach(..., id: .self)?

i think this is a good idea -- to assign a UUID to most everything that i might need to locate later; and Identifiable makes it easier to use with ForEach.

thanks for asking this question! it helps me with a problem i am working on and was pretty much settled on what was happening and how to solve it, but this code helped me think a little more clearly about it, or at least arrive at a plausible explanation.

hope that helps,

DMG

2      

Hi DMG,

thanks for your answer. I think it will work for my textfield. But what if I want to use a Toggle() instead? If I insert a local State-Variable for the isOn-Paramter, there is no way to update the model with a completion handler, because Toggle does not offer an initializer with completion handler. How can I then save back my changes in the Toggle to the model?

Nico

2      

hi Nico,

sorry for the slightly tortured explanation i gave earlier ... i reread that this morning and i still have confusion ... but at least we got what you needed. and it was especially helpful that TextField offered you an .onEditingChanged option to do a live update.

but then you asked about a Toggle, which has no such hook in it (at least in XCode 11.5/iOS 13.5 -- and XCode 12b2 does not appear to have one either).

so i would propose a general situation for you:

  • SubView has @State private variables for the text being edited and whether the item is on or off.
  • SubView comes on-screen, uses the .onAppear() modifier to offload the values of these from the item to the local variables.
  • when SubView goes off-screen, transfer the local variable values back to the item, via the model.

how this last step is done depends on the presentation. if SubView was presented with a navigation link, then hook into its .onDisappear() modifier to do the transfer back to the item. if SubView was presented as a sheet, then the sheet probably has a "Save" button on it so that, when tapped, it does the transfer and dismisses the sheet.

here's suitable code for the navigation situation that you have:

struct SubView: View {
    var item: Item
    @EnvironmentObject var model: Model
    @State private var textFieldContents = ""
    @State private var toggleIsOn: Bool = false

    var body: some View {
        Form {
            TextField("Title", text: $textFieldContents)
            Toggle(isOn: $toggleIsOn) {
                Text("Is On")
            }
        }
        .navigationBarTitle("Detail", displayMode: .inline)
        .onAppear(perform: loadItemData)
        .onDisappear(perform: updateItemData)
    }

    func loadItemData() { // transfer values from the item to local variables
        textFieldContents = item.title
        toggleIsOn = item.isOn
    }

    func updateItemData() { // ask the model to update this item with the local values
        model.updateValues(for: item, newTitle: textFieldContents, newOn: toggleIsOn)
    }

}

hope that helps,

DMG

2      

I ran into the same issue, my workaround was slightly different.

In the sub view, I left itemIndex as an optional. Then in the body I check if itemIndex is nil. If it is nil show only the text "No data" is displayed (note: this should never occur), otherwise show my regular view.

Since itemIndex is no longer being forced unwrapped, we no longer have crashes when deleting items.

All together it looks like this:

import SwiftUI

struct SubView: View {
    @EnvironmentObject var itemStore: ItemStore
    var item: Item

    var itemIndex: Int? {
        itemStore.items.firstIndex(where: { $0.id == item.id })
    }

    var body: some View {
        if itemIndex == nil {
            return AnyView(
                Text("No data")
            )
        } else {
            return AnyView(
                ItemDetails(item: $itemStore.items[itemIndex!])
            )
        }
    }
}

2      

TAKE YOUR SKILLS TO THE NEXT LEVEL If you like Hacking with Swift, you'll love Hacking with Swift+ – it's my premium service where you can learn advanced Swift and SwiftUI, functional programming, algorithms, and more. Plus it comes with stacks of benefits, including monthly live streams, downloadable projects, a 20% discount on all books, and free gifts!

Find out more

Sponsor Hacking with Swift and reach the world's largest Swift community!

Archived topic

This topic has been closed due to inactivity, so you can't reply. Please create a new topic if you need to.

All interactions here are governed by our code of conduct.

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.