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
How to Use Key-Value Observing with Smart Keypaths in Swift 4
Weak Method Argument Semantics
iOS Swift Didbegincontact Not Being Called
Using @Fetchrequest(Entity: ) for Swiftui MACos App Crashes
How to Subclass a Class Which Doesn't Have Any Designated Initializers
Make a Type Itself -- Not Its Instances -- Conform to a Protocol
String Comparison in Swift Is Not Transitive
Swift Extension Storage for Protocols
Swift Combine Publishers VS Completion Handler and When to Cancel
How to Do a Long Press in Swift
Typecast Unsafemutablepointer<Void> to Unsafemutablepointer<#Struct Type#>
Swiftlint Overriding Project Settings Related to Spm
Does Untimeintervalnotificationtrigger Nexttriggerdate() Give the Wrong Date
Why Does Type(Of:) Return Metatype, Rather Than T.Type
How to Add Multiple Values for One Key in a Dictionary Using Swift
Swift and Objectmapper: Nsdate with Min Value
Can a Subclass Override a Function and Make More Restrictive Return