Randall
Randall

Reputation: 14839

How to make a non-ObservableObject observable?

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

Answers (2)

New Dev
New Dev

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

Asperi
Asperi

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.

demo

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

Related Questions