3

Suppose I have a Model structure like this:

struct Model: Identifiable {
    let id = UUID()
    var data: String
}

It simply contains data and an id field, which I see the Apple SwiftUI examples commonly do.

In a ViewModel class, I have this:

class ViewModel: ObservableObject {
    @Published var models: [Model] = []
    @Published var modelSelection = Set<Model.ID>()
}

I use it like this:

List(viewModel.models, selection: $viewModel.modelSelection)

Now I have a DetailView, which basically displays & modifies the first selected model via a Binding.

In order to do this, it must get the first selected models id, and then find that model in ViewModel.models. So I created a subscript property in ViewModel:

subscript(_ modelID: Model.ID) -> Model? {
    get {
        models.first(where: { $0.id == modelID })
    }
    set {
        guard let index = models.firstIndex(where: { $0.id == modelID }) else { return }
        if let newValue = newValue {
            models[index] = newValue
        }
        else {
            models.remove(at: index)
        }
    }
}

Now here's DetailView. It simply is a TextField that modifies the first selected Model:

struct DetailView: View {
    @EnvironmentObject private var viewModel: ViewModel

    var body: some View {
        if let id = viewModel.modelSelection.first,
           viewModel[id] != nil,
           let binding = Binding($viewModel[id]) {
            VStack { // VStack is important! It only crashes if VStack is enclosing.
                TextField("", text: binding.data)
            }
        }
        else {
            Text("No (or multiple) Selection")
        }
    }
}

The problem comes when I try to remove the currently selected model. I crash with an error in:

BindingOperations.ForceUnwrapping.get(base:)

The crash only happens with an enclosing VStack in DetailView.

I think this crash happens because SwiftUI is referencing the binding local variable which contains an id that has now been invalidated/removed (or it doesn't update DetailView.body, and continues using the old binding).

Here's the full MRE code. To get the crash, select an item from the sidebar, and then press remove.

struct ContentView: View {
    @StateObject private var viewModel = ViewModel()
    
    var body: some View {
        NavigationView {
            List(viewModel.models, selection: $viewModel.modelSelection) { model in
                Text(model.data)
            }
            
            Button("Remove Currently Selected") {
                // Note: I'm not talking about this part unwrapping.
                viewModel[viewModel.modelSelection.first!] = nil
            }
            
            DetailView(viewModel: viewModel)
        }
    }
}

struct Model: Identifiable {
    let id = UUID()
    var data: String
}
class ViewModel: ObservableObject {
    @Published var models: [Model] = [Model(data: "Hi"), Model(data: "Bye")]
    @Published var modelSelection = Set<Model.ID>()
    
    subscript(_ modelID: Model.ID) -> Model? {
        get {
            models.first(where: { $0.id == modelID })
        }
        set {
            guard let index = models.firstIndex(where: { $0.id == modelID }) else { return }
            if let newValue = newValue {
                models[index] = newValue
            }
            else {
                models.remove(at: index)
            }
        }
    }
}

struct DetailView: View {
    @ObservedObject var viewModel: ViewModel

    var body: some View {
        if let id = viewModel.modelSelection.first,
           viewModel[id] != nil,
           let binding = Binding($viewModel[id]) {
            VStack {
                TextField("", text: binding.data)
            }
        }
        else {
            Text("No (or multiple) Selection")
        }
    }
}

I was just wondering what is the best way to handle a situation like this (Models with ID's and accessing them with bindings).

I see that in the WWDC 2021 SwiftUI Garden App example code, instead of returning an optional from the subscript in ViewModel, they just force unwrap or return a placeholder object.

However, returning a placeholder object seems meh because in the actual Model I have 10+ fields.

Is there any other way?

Edit: After seeing the answers on this post and also this post, it seems like the best way is to simply use a placeholder.

  • I would look into adding a property for the model data to your view model that is either set to the value of `data` for the currently selected model or "" if no model is selected and then use this property instead in your view. This also mean you can remove the ugly `if` from your detail view since you never have an optional to deal with in the view. – Joakim Danielson Jan 02 '22 at 08:44
  • @JoakimDanielson I think this is similar to the Apple method. I will try this out, thanks for the suggestion! –  Jan 02 '22 at 08:49

0 Answers0