1

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

4
  • Maybe class ObservableStuff { @Published var model = Model() }? And then inside BrokenView, @StateObject var stuff = ObservableStuff(). Use Text(stuff.model.number) for the text. Commented Jun 19, 2021 at 1:01
  • 1
    @aheze, this wouldn't work, right, since Model is a class Commented Jun 19, 2021 at 2:38
  • @NewDev you're right. Commented Jun 19, 2021 at 2:41
  • @Randall maybe check this out: hackingwithswift.com/quick-start/swiftui/… Commented Jun 19, 2021 at 2:41

2 Answers 2

1

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()!
        }
    }
}
Sign up to request clarification or add additional context in comments.

1 Comment

This works really well! I tried using @dynamicCallable to get it to forward along function calls as well, but that doesn't look like it will work. Making the model read only is a decent enough work around for that.
0

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
    }
}

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.