10

Trying to implement a custom property wrapper which would also publish its changes the same way @Publish does. E.g. allow my SwiftUI to receive changes on my property using my custom wrapper.

The working code I have:

import SwiftUI

@propertyWrapper
struct MyWrapper<Value> {
    var value: Value

    init(wrappedValue: Value) { value = wrappedValue }

    var wrappedValue: Value {
        get { value }
        set { value = newValue }
    }
}

class MySettings: ObservableObject {
    @MyWrapper
    public var interval: Double = 50 {
        willSet { objectWillChange.send() }
    }
}

struct MyView: View {
    @EnvironmentObject var settings: MySettings

    var body: some View {
        VStack() {
            Text("\(settings.interval, specifier: "%.0f")").font(.title)
            Slider(value: $settings.interval, in: 0...100, step: 10)
        }
    }
}

struct MyView_Previews: PreviewProvider {
    static var previews: some View {
        MyView().environmentObject(MySettings())
    }
}

However, I do not like the need to call objectWillChange.send() for every property in MySettings class.

The @Published wrapper works well, so I tried to implement it as part of @MyWrapper, but I was not successful.

A nice inspiration I found was https://github.com/broadwaylamb/OpenCombine, but I failed even when trying to use the code from there.

When struggling with the implementation, I realised that in order to get @MyWrapper working I need to precisely understand how @EnvironmentObject and @ObservedObject subscribe to changes of @Published.

Any help would be appreciated.

Pavel Lobodinský
  • 1,028
  • 1
  • 12
  • 25
  • Here is a topic that might be interesting for you [Is it correct to expect internal updates of a SwiftUI DynamicProperty property wrapper to trigger a view update?](https://stackoverflow.com/a/59622915/12299030) – Asperi Jan 25 '20 at 20:19
  • Thanks! This approach, however, seems not to work for `ObservableObject` classes :( – Pavel Lobodinský Jan 25 '20 at 21:04

1 Answers1

8

Until the https://github.com/apple/swift-evolution/blob/master/proposals/0258-property-wrappers.md#referencing-the-enclosing-self-in-a-wrapper-type gets implemented, I came up with the solution below.

Generally, I pass the objectWillChange reference of the MySettings to all properties annotated with @MyWrapper using reflection.

import Cocoa
import Combine
import SwiftUI

protocol PublishedWrapper: class {
    var objectWillChange: ObservableObjectPublisher? { get set }
}

@propertyWrapper
class MyWrapper<Value>: PublishedWrapper {
    var value: Value
    weak var objectWillChange: ObservableObjectPublisher?

    init(wrappedValue: Value) { value = wrappedValue }

    var wrappedValue: Value {
        get { value }
        set {
            value = newValue
            objectWillChange?.send()
        }
    }
}

class MySettings: ObservableObject {
    @MyWrapper
    public var interval1: Double = 10

    @MyWrapper
    public var interval2: Double = 20

    /// Pass our `ObservableObjectPublisher` to the property wrappers so that they can announce changes
    init() {
        let mirror = Mirror(reflecting: self)
        mirror.children.forEach { child in
            if let observedProperty = child.value as? PublishedWrapper {
                observedProperty.objectWillChange = self.objectWillChange
            }
        }
    }
}

struct MyView: View {
    @EnvironmentObject
    private var settings: MySettings

    var body: some View {
        VStack() {
            Text("\(settings.interval1, specifier: "%.0f")").font(.title)
            Slider(value: $settings.interval1, in: 0...100, step: 10)

            Text("\(settings.interval2, specifier: "%.0f")").font(.title)
            Slider(value: $settings.interval2, in: 0...100, step: 10)
        }
    }
}

struct MyView_Previews: PreviewProvider {
    static var previews: some View {
        MyView().environmentObject(MySettings())
    }
}
Pavel Lobodinský
  • 1,028
  • 1
  • 12
  • 25
  • This gives me a publishing changes from background thread warning. – leonboe1 Nov 23 '20 at 20:39
  • How about using `.receive(on: RunLoop.main)`? – Pavel Lobodinský Nov 24 '20 at 07:40
  • Thanks for your quick reply! However, I don't understand where to use .receive. Could you explain that to me? – leonboe1 Nov 24 '20 at 11:22
  • Have a look here where it is explained what to do when the publisher results update the UI. https://developer.apple.com/documentation/combine/receiving-and-handling-events-with-combine – Pavel Lobodinský Nov 24 '20 at 15:43
  • Thanks, I learned a lot. However, I'm still unsure where to use ```.receive```. Also, there is a problem with animations. If you use ```withAnimation{}```, animating changes won't work with that property wrapper. That's because when ```set{}``` runs, ```objectWillChange?.send()``` can't listen for changes anymore because the variable has already been set. Firing ```objectWillChange?.send()``` in ```willSet{}``` of ```value``` works for me (with animations), but gives me the warning. Wrapping it in ```DispatchQueue.main.async``` doesn't work (because that delays the execution time). – leonboe1 Nov 24 '20 at 18:20
  • That might have been an error in my code. Sorry. – leonboe1 Nov 27 '20 at 15:50