'Is it correct to expect internal updates of a SwiftUI DynamicProperty property wrapper to trigger a view update?

I'm attempting to create a custom property wrapper supported by SwiftUI, meaning that changes to the corresponding properties values would cause an update to the SwiftUI view. Here is a simplified version of what I have:

@propertyWrapper
public struct Foo: DynamicProperty {
    @ObservedObject var observed: SomeObservedObject

    public var wrappedValue: [SomeValue] {
        return observed.value
    }
}

I see that even if my ObservedObject is contained inside of my custom property wrapper, SwiftUI still catches the changes to SomeObservedObject as long as:

  • My property wrapper is a struct
  • My property wrapper conforms to DynamicProperty

Unfortunately the docs are sparse and I have a hard time telling if this only works out of luck with the current SwiftUI implementation.

The docs of DynamicProperty (within Xcode, not online) seem to indicate that such a property is a property that is changed from the outside causing the view to redraw, but there is no guarantee about what happens when you conform your own types to this protocol.

Can I expect this to continue working in future SwiftUI releases?



Solution 1:[1]

Ok... here is alternate approach to get similar thing... but as struct only DynamicProperty wrapped around @State (to force view refresh).

It is simple wrapper but gives possibility to incapsulate any custom calculations with following view refresh... and as said using value-only types.

Here is demo (tested with Xcode 11.2 / iOS 13.2):

DynamicProperty as wrapper on @State

Here is code:

import SwiftUI

@propertyWrapper
struct Refreshing<Value> : DynamicProperty {
    let storage: State<Value>

    init(wrappedValue value: Value) {
        self.storage = State<Value>(initialValue: value)
    }
    
    public var wrappedValue: Value {
        get { storage.wrappedValue }
        
        nonmutating set { self.process(newValue) }
    }
    
    public var projectedValue: Binding<Value> {
        storage.projectedValue
    }
    
    private func process(_ value: Value) {
        // do some something here or in background queue
        DispatchQueue.main.async {
            self.storage.wrappedValue = value
        }
    }
    
}


struct TestPropertyWrapper: View {
    
    @Refreshing var counter: Int = 1
    var body: some View {
        VStack {
            Text("Value: \(counter)")
            Divider()
            Button("Increase") {
                self.counter += 1
            }
        }
    }
}

backup

Solution 2:[2]

For the question in your title, no. For example, here is some code that doesn't work:

class Storage {
    var value = 0
}

@propertyWrapper
struct MyState: DynamicProperty {
    var storage = Storage()

    var wrappedValue: Int {
        get { storage.value }

        nonmutating set {
            storage.value = newValue // This doesn't work
        }
    }
}

So apparently you still need to put a State or ObservedObject etc. inside your DynamicProperty to trigger an update as Asperi did, a DynamicProperty itself doesn't enforce any update.

Solution 3:[3]

You can create and use a Combine publisher with your @propertyWrapper the same way a @Published object from SwiftUI would do.

By passing the publisher of your @porpertyWrapper to a projectedValue, you will have a custom Combine publisher that you can use within your SwiftUI view and call the $ to keep track of the value changes over time.

The use in your SwiftUI view or inside a view model:

@Foo(defaultValue: "foo") var value: String

// For your view model or SwiftUI View
$value

Your full custom Combine publisher as a @propertyWrapper:

import Combine

@propertyWrapper
struct Foo<Value> {

  var defaultValue: Value

  // Combine publisher to project value over time. 
  private let publisher = PassthroughSubject<Value, Never>()

  var wrappedValue: Value {
    get {
      return defaultValue
    }
    set {
      defaultValue = newValue
      publisher.send(newValue)
    }
  }

  // Project the updated value using the Combine publisher.
  var projectedValue: AnyPublisher<Value, Never> {
    publisher.eraseToAnyPublisher()
  }
}

Solution 4:[4]

Yes this is correct, here is an example:

class SomeObservedObject : ObservableObject {
    @Published var counter = 0
}

@propertyWrapper struct Foo: DynamicProperty {

    @StateObject var object = SomeObservedObject()

    public var wrappedValue: Int {
        get {
            object.counter
        }
        nonmutating set {
            object.counter = newValue
        }
    }
}

When a View using @Foo is recreated, SwiftUI passes the Foo struct the same object as last time, so has the same counter value. When setting the foo var, this is set on the ObservableObject's @Published which SwiftUI detects as a change and causes the body to be recomputed.

Try it out!

struct ContentView: View {
    @State var counter = 0
    var body: some View {
        VStack {
            Text("\(counter) Hello, world!")
            Button("Increment") {
                counter = counter + 1
            }
            ContentView2()
        }
        .padding()
    }
}

struct ContentView2: View {
    @Foo var foo
    
    var body: some View {
        VStack {
            Text("\(foo) Hello, world!")
            Button("Increment") {
                foo = foo + 1
            }
        }
        .padding()
    }
}

When the second button is tapped the counter stored in Foo is updated. When first button is tapped, ContentView's body is called and ContentView2() is recreated but keeps the same counter as last time. Now SomeObservedObject can be a NSObject and implement a delegate protocol for example.

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1
Solution 2 Cosyn
Solution 3 Roland Lariotte
Solution 4 malhal