问题
I am trying to make a ObservableObject that has properties that wrap a UserDefaults variable.
In order to conform to ObservableObject, I need to wrap the properties with @Published. Unfortunately, I cannot apply that to computed properties, as I use for the UserDefaults values.
How could I make it work? What do I have to do to achieve @Published behaviour?
回答1:
For an existing @Published property
Here's one way to do it, you can create a lazy property that returns a publisher derived from your @Published publisher:
import Combine
class AppState: ObservableObject {
@Published var count: Int = 0
lazy var countTimesTwo: AnyPublisher<Int, Never> = {
$count.map { $0 * 2 }.eraseToAnyPublisher()
}()
}
let appState = AppState()
appState.count += 1
appState.$count.sink { print($0) }
appState.countTimesTwo.sink { print($0) }
// => 1
// => 2
appState.count += 1
// => 2
// => 4
However, this is contrived and probably has little practical use. See the next section for something more useful...
For any object that supports KVO
UserDefaults supports KVO. We can create a generalizable solution called KeyPathObserver that reacts to changes to an Object that supports KVO with a single @ObjectObserver. The following example will run in a Playground:
import Foundation
import UIKit
import PlaygroundSupport
import SwiftUI
import Combine
let defaults = UserDefaults.standard
extension UserDefaults {
@objc var myCount: Int {
return integer(forKey: "myCount")
}
var myCountSquared: Int {
return myCount * myCount
}
}
class KeyPathObserver<T: NSObject, V>: ObservableObject {
@Published var value: V
private var cancel = Set<AnyCancellable>()
init(_ keyPath: KeyPath<T, V>, on object: T) {
value = object[keyPath: keyPath]
object.publisher(for: keyPath)
.assign(to: \.value, on: self)
.store(in: &cancel)
}
}
struct ContentView: View {
@ObservedObject var defaultsObserver = KeyPathObserver(\.myCount, on: defaults)
var body: some View {
VStack {
Text("myCount: \(defaults.myCount)")
Text("myCountSquared: \(defaults.myCountSquared)")
Button(action: {
defaults.set(defaults.myCount + 1, forKey: "myCount")
}) {
Text("Increment")
}
}
}
}
let viewController = UIHostingController(rootView: ContentView())
PlaygroundPage.current.liveView = viewController
note that we've added an additional property myCountSquared to the UserDefaults extension to calculate a derived value, but observe the original KeyPath.
回答2:
When Swift is updated to enable nested property wrappers, the way to do this will probably be to create a @UserDefault property wrapper and combine it with @Published.
In the mean time, I think the best way to handle this situation is to implement ObservableObject manually instead of relying on @Published. Something like this:
class ViewModel: ObservableObject {
let objectWillChange = ObservableObjectPublisher()
var name: String {
get {
UserDefaults.standard.string(forKey: "name") ?? ""
}
set {
objectWillChange.send()
UserDefaults.standard.set(newValue, forKey: "name")
}
}
}
Property wrapper
As I mentioned in the comments, I don't think there is a way to wrap this up in a property wrapper that removes all boilerplate, but this is the best I can come up with:
@propertyWrapper
struct PublishedUserDefault<T> {
private let key: String
private let defaultValue: T
var objectWillChange: ObservableObjectPublisher?
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 {
objectWillChange?.send()
UserDefaults.standard.set(newValue, forKey: key)
}
}
}
class ViewModel: ObservableObject {
let objectWillChange = ObservableObjectPublisher()
@PublishedUserDefault(key: "name")
var name: String = "John"
init() {
_name.objectWillChange = objectWillChange
}
}
You still need to declare objectWillChange and connect it to your property wrapper somehow (I'm doing it in init), but at least the property definition itself it pretty simple.
回答3:
Updated:
I've managed to add support for the $variable syntax. However, for the enclosing ObservableObject to propagate changes, I manually observe the UserDefaults themselves:
class Settings: ObservableObject {
let objectWillChange = PassthroughSubject<Void, Never>()
private var didChangeCancellable: AnyCancellable?
private init(){
didChangeCancellable = NotificationCenter.default
.publisher(for: UserDefaults.didChangeNotification)
.map { _ in () }
.receive(on: DispatchQueue.main)
.subscribe(objectWillChange)
}
static var shared = Settings()
@Setting(key: "test") var isTest = false
}
@propertyWrapper
public struct Setting<T> {
let key: String
let defaultValue: T
init(wrappedValue value: T, key: String) {
self.key = key
self.defaultValue = value
}
public var wrappedValue: T {
get {
let val = UserDefaults.standard.object(forKey: key) as? T
return val ?? defaultValue
}
set {
objectWillChange?.send()
publisher?.subject.value = newValue
UserDefaults.standard.set(newValue, forKey: key)
}
}
/// NEW
public struct Publisher: Combine.Publisher {
public typealias Output = T
public typealias Failure = Never
public func receive<Downstream: Subscriber>(subscriber: Downstream)
where Downstream.Input == T, Downstream.Failure == Never {
subject.subscribe(subscriber)
}
fileprivate let subject: Combine.CurrentValueSubject<T, Never>
fileprivate init(_ output: Output) {
subject = .init(output)
}
}
private var publisher: Publisher?
internal var objectWillChange: ObservableObjectPublisher?
public var projectedValue: Publisher {
mutating get {
if let publisher = publisher {
return publisher
}
let publisher = Publisher(wrappedValue)
self.publisher = publisher
return publisher
}
}
}
来源:https://stackoverflow.com/questions/59036863/add-publisher-behaviour-for-computed-property