Reputation: 14839
I have a handful of model objects that come in from an external SDK so I can't change their code. They are all mutable.
I have a view that uses these objects to drive its display. When making changes to the objects, these changes aren't reflected in their view.
Here's a very simple example:
class Model {
var number: String = "One"
}
struct BrokenView: View {
let model = Model()
var body: some View {
Text(model.number)
Button("Change") {
model.number = ["One", "Two", "Three", "Four", "Five", "Six"].randomElement()!
}
}
}
This makes complete sense because Model isn't publishing its changes so there's no way for SwiftUI to know it needs to rebuild the view.
My question is how do I get SwiftUI to listen to changes in Model objects?
I've come up with two solutions, neither of which I love.
The first is to add an updater
@State variable that I can toggle whenever I make the change. This actually works pretty well. I can even pass a binding down to this variable to subview and have it rebuild the whole view. Obviously this doesn't seem like a great solution.
struct HackyView: View {
let model = Model()
@State private var updater: Bool = false
var body: some View {
Text(model.number)
Button("Change") {
model.number = ["One", "Two", "Three", "Four", "Five", "Six"].randomElement()!
updater.toggle()
}
if updater {
EmptyView()
}
}
}
My next solution is wrapping each of the model classes in an ObservableObject
with @Published properties. This feels a little better, but it's a lot of extra work.
struct WrapperView: View {
@StateObject var model = PublishedModel(model: Model())
var body: some View {
Text(model.number)
Button("Change") {
model.number = ["One", "Two", "Three", "Four", "Five", "Six"].randomElement()!
}
}
class PublishedModel: ObservableObject {
let model: Model
init(model: Model) {
self.model = model
self.number = model.number
}
@Published var number: String {
didSet {
model.number = number
}
}
}
}
I think my ideal solution would be some sort of extension or generic wrapper class that can make these properties @Published so the view knows they've changed. Is there any way to do that?
Here is a GitHub gist you can copy and paste into an empty Xcode project if you want to give this a try. https://gist.github.com/blladnar/4b2d1eb419151c5126c28d9da8646e92
Upvotes: 1
Views: 981
Reputation: 49590
A possible approach here is to wrap your model in an ObservableObject
- like your second approach, but more extensible that works with any object by using dynamicMemberLookup
.
@dynamicMemberLookup
class Observable<M: AnyObject>: ObservableObject {
var model: M
init(_ model: M) {
self.model = model
}
subscript<T>(dynamicMember kp: WritableKeyPath<M, T>) -> T {
get { model[keyPath: kp] }
set {
self.objectWillChange.send() // signal change on property update
model[keyPath: kp] = newValue
}
}
}
The usage is:
struct UnBrokenView: View {
@StateObject var model = Observable(Model()) // wrap in Observable
var body: some View {
Text(model.number)
Button("Change") {
model.number = ["One", "Two", "Three", "Four", "Five", "Six"].randomElement()!
}
}
}
Upvotes: 1
Reputation: 257889
Here is a possible solution, although requires additional call, but decreases a lot of work in your case - approach is based on property wrapper feature joined with State
dynamic property that forces view update.
Tested with Xcode 13beta / iOS 15
Note: a view will be updated on new model object set to property as well.
class Model { // no changes here
var number: String = "One"
}
struct BrokenView: View {
@Updatable var model = Model() // << here - wrapper !!
var body: some View {
Text(model.number)
Button("Change") {
model.number = ["One", "Two", "Three", "Four", "Five", "Six"].randomElement()!
_model.setNeedsUpdate() // << here - update !!
}
}
}
@propertyWrapper
struct Updatable<Value: AnyObject> : DynamicProperty {
let storage: State<(Value, Bool)>
init(wrappedValue value: Value) {
self.storage = State<(Value, Bool)>(initialValue: (value, false))
}
public var wrappedValue: Value {
get { storage.wrappedValue.0 }
nonmutating set {
self.storage.wrappedValue = (newValue, false)
}
}
nonmutating func setNeedsUpdate() {
self.storage.wrappedValue.1.toggle()
}
public var projectedValue: Binding<Value> {
storage.projectedValue.0
}
}
Upvotes: 0