Combining Custom Property Wrapper with @Published

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

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

Custom Property Wrapper that Updates View Swift

The solution to this is to make a minor tweak to the solution of the singleton. Credits to @user1046037 for pointing this out to me. The problem with the singleton fix mentioned in the original post, is that it does not retain the canceller for the sink in the initializer. Here is the correct code:

class Session: ObservableObject {

@DatabaseBackedArray(\.schools, events: .all, actions: [.on(.constructive) { $0.sort { $0.name < $1.name } }])
var schools: [School] = []

private var cancellers = [AnyCancellable]()

private init() {
_schools.objectWillChange.sink {
self.objectWillChange.send()
}.assign(to: &cancellers)
}

static let current = Session()

}

Mimic Swift Combine @Published to create @PublishedAppStorage

Thanks for the direction from David, I've managed to get it working. I'm posting here my final Property Wrapper in case anyone finds it helpful.

import SwiftUI
import Combine

@propertyWrapper
public struct PublishedAppStorage<Value> {

// Based on: https://github.com/OpenCombine/OpenCombine/blob/master/Sources/OpenCombine/Published.swift

@AppStorage
private var storedValue: Value

private var publisher: Publisher?
internal var objectWillChange: ObservableObjectPublisher?

/// A publisher for properties marked with the `@Published` attribute.
public struct Publisher: Combine.Publisher {

public typealias Output = Value

public typealias Failure = Never

public func receive<Downstream: Subscriber>(subscriber: Downstream)
where Downstream.Input == Value, Downstream.Failure == Never
{
subject.subscribe(subscriber)
}

fileprivate let subject: Combine.CurrentValueSubject<Value, Never>

fileprivate init(_ output: Output) {
subject = .init(output)
}
}

public var projectedValue: Publisher {
mutating get {
if let publisher = publisher {
return publisher
}
let publisher = Publisher(storedValue)
self.publisher = publisher
return publisher
}
}

@available(*, unavailable, message: """
@Published is only available on properties of classes
""")
public var wrappedValue: Value {
get { fatalError() }
set { fatalError() }
}

public static subscript<EnclosingSelf: ObservableObject>(
_enclosingInstance object: EnclosingSelf,
wrapped wrappedKeyPath: ReferenceWritableKeyPath<EnclosingSelf, Value>,
storage storageKeyPath: ReferenceWritableKeyPath<EnclosingSelf, PublishedAppStorage<Value>>
) -> Value {
get {
return object[keyPath: storageKeyPath].storedValue
}
set {
// https://stackoverflow.com/a/59067605/14314783
(object.objectWillChange as? ObservableObjectPublisher)?.send()
object[keyPath: storageKeyPath].publisher?.subject.send(newValue)
object[keyPath: storageKeyPath].storedValue = newValue
}
}

// MARK: - Initializers

// RawRepresentable
init(wrappedValue: Value, _ key: String, store: UserDefaults? = nil) where Value : RawRepresentable, Value.RawValue == String {
self._storedValue = AppStorage(wrappedValue: wrappedValue, key, store: store)
}

// String
init(wrappedValue: String, _ key: String, store: UserDefaults? = nil) where Value == String {
self._storedValue = AppStorage(wrappedValue: wrappedValue, key, store: store)
}

// Data
init(wrappedValue: Data, _ key: String, store: UserDefaults? = nil) where Value == Data {
self._storedValue = AppStorage(wrappedValue: wrappedValue, key, store: store)
}

// Int
init(wrappedValue: Int, _ key: String, store: UserDefaults? = nil) where Value == Int {
self._storedValue = AppStorage(wrappedValue: wrappedValue, key, store: store)
}

// URL
init(wrappedValue: URL, _ key: String, store: UserDefaults? = nil) where Value == URL {
self._storedValue = AppStorage(wrappedValue: wrappedValue, key, store: store)
}

// Double
init(wrappedValue: Double, _ key: String, store: UserDefaults? = nil) where Value == Double {
self._storedValue = AppStorage(wrappedValue: wrappedValue, key, store: store)
}

// Bool
init(wrappedValue: Bool, _ key: String, store: UserDefaults? = nil) where Value == Bool {
self._storedValue = AppStorage(wrappedValue: wrappedValue, key, store: store)
}

}

And this how it is used:

public class MyDefaults: ObservableObject
{
@PublishedAppStorage("useVerboseLog")
public var useVerboseLog: Bool = true
}

Add @Published behaviour for computed property

Updated:
With the EnclosingSelf subscript, one can do it!

Works like a charm!

import Combine
import Foundation

class LocalSettings: ObservableObject {
static var shared = LocalSettings()

@Setting(key: "TabSelection")
var tabSelection: Int = 0
}

@propertyWrapper
struct Setting<T> {
private let key: String
private let defaultValue: T

init(wrappedValue value: T, key: String) {
self.key = key
self.defaultValue = value
}

var wrappedValue: T {
get {
UserDefaults.standard.object(forKey: key) as? T ?? defaultValue
}
set {
UserDefaults.standard.set(newValue, forKey: key)
}
}

public static subscript<EnclosingSelf: ObservableObject>(
_enclosingInstance object: EnclosingSelf,
wrapped wrappedKeyPath: ReferenceWritableKeyPath<EnclosingSelf, T>,
storage storageKeyPath: ReferenceWritableKeyPath<EnclosingSelf, Setting<T>>
) -> T {
get {
return object[keyPath: storageKeyPath].wrappedValue
}
set {
(object.objectWillChange as? ObservableObjectPublisher)?.send()
UserDefaults.standard.set(newValue, forKey: object[keyPath: storageKeyPath].key)
}
}
}

Combine assign(to:) with a @Published var

assign(to:) requires a Published.Publisher as its input. So you need to pass the publisher from the Publisher property wrapper, which you can access using the $ prefix.

.assign(to: &$content)

Custom property wrapper not accurately reflecting the state of my TextField in SwiftUI, any idea why?

SwiftUI watches @Published properties in @StateObject or @ObservedObject and triggers UI update on changes of them.

But it does not go deep inside the ObservableObject. Your FormDataViewModel does not have any @Published properties.


One thing you can do is that simulating what @Published will do on value changes.

class FormDataViewModel: ObservableObject {
@Capitalized var name: String = ""
private var nameObserver: AnyCancellable?

init() {
nameObserver = _name.$value.sink {_ in
self.objectWillChange.send()
}
}
}

Please try.

How to seamlessly wrap @Published variables with a UserDefaults propertyWrapper

You can fix your compilation errors by using where Value : Codable to restrict your extension. Then, you can get rid of your T generic altogether (& you don't have to use the type argument either):

extension Published where Value : Codable {
init(wrappedValue defaultValue: Value, key: String) {
let decoder = JSONDecoder()
var value: Value
if
let data = UserDefaults.standard.data(forKey: key),
let decodedVal = try? decoder.decode(Value.self, from: data) {
value = decodedVal
} else {
value = defaultValue
}

self.init(initialValue: value)

cancellables[key] = projectedValue.sink { val in
let encoder = JSONEncoder()
do {
let encodedVal = try encoder.encode(val)
UserDefaults.standard.set(encodedVal, forKey: key)
} catch {
print(error)
assertionFailure(error.localizedDescription)
}
}
}
}

This being said, I'd probably take the path instead of creating a custom property wrapper instead that wraps @AppStorage instead of extending @Published



Related Topics



Leave a reply



Submit