How to implement a custom property wrapper which would publish the changes for SwiftUI to re-render it's view
Asked Answered
H

1

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.

Hogue answered 25/1, 2020 at 18:54 Comment(2)
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?Sadi
Thanks! This approach, however, seems not to work for ObservableObject classes :(Eskew
H
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())
    }
}
Hogue answered 26/8, 2020 at 19:1 Comment(6)
This gives me a publishing changes from background thread warning.Normative
How about using .receive(on: RunLoop.main)?Eskew
Thanks for your quick reply! However, I don't understand where to use .receive. Could you explain that to me?Normative
Have a look here where it is explained what to do when the publisher results update the UI. developer.apple.com/documentation/combine/…Eskew
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).Normative
That might have been an error in my code. Sorry.Normative

© 2022 - 2024 — McMap. All rights reserved.