Reputation: 1846
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?
Upvotes: 9
Views: 8882
Reputation: 30617
Now we have @AppStorage
for this:
App Storage
A property wrapper type that reflects a value from UserDefaults and invalidates a view on a change in value in that user default.
https://developer.apple.com/documentation/swiftui/appstorage
Upvotes: 1
Reputation: 1846
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)
}
}
}
Upvotes: 5
Reputation: 35900
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...
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
.
Upvotes: 9
Reputation: 5941
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")
}
}
}
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.
Upvotes: 13