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?
A Computed Property provides a getter and an optional setter to indirectly access other properties and values. It can be used in several ways. A common use-case is to derive value from other properties.
In short, the first is a stored property that is initialized via a closure, with that closure being called only one time, when it is initialized. The second is a computed property whose get block is called every time you reference that property.
Computed properties are for creating custom get and set methods for stored properties. Computed properties are provided by classes, structures, and enumerations to provide custom behavior for properties. Stored and computed properties are usually associated with a particular type but can be associated with any type.
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.
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
.
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)
}
}
}
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
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With