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.