Created
October 16, 2020 16:40
-
-
Save Gernot/9d61dff3d7579b7cdaa5ed6760ab502f to your computer and use it in GitHub Desktop.
Model with Properties form AppStorage
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import Foundation | |
import SwiftUI | |
import Combine | |
import PlaygroundSupport | |
struct Model { | |
@AppStorage(wrappedValue: true, "Test") var isEnabled | |
} | |
struct MyView: View { | |
@State var model = Model() | |
@AppStorage(wrappedValue: true, "Test") var valueFromPreferences | |
var body: some View { | |
Form { | |
Toggle("State", isOn: model.$isEnabled) | |
Toggle("Preferences", isOn: $valueFromPreferences) | |
} | |
} | |
} | |
PlaygroundPage.current.setLiveView(MyView()) |
Unfortunately this has the same issues as the original, see this Twitter Thread
Here is as far as I am with writing a custom Property Wrapper that observes the settings value:
import Foundation
import SwiftUI
import Combine
import PlaygroundSupport
private var keyChangedContext = "KeyChangedContext"
@propertyWrapper
class Storage<T>: NSObject, DynamicProperty {
init(wrappedValue defaultValue: T, _ key: String, store: UserDefaults = UserDefaults.standard) {
self.defaultValue = defaultValue
self.key = key
self.store = store
super.init()
store.addObserver(self, forKeyPath: key, options: [], context: &keyChangedContext)
}
deinit {
store.removeObserver(self, forKeyPath: key)
}
private let store: UserDefaults
private let key: String
private let defaultValue: T
var wrappedValue: T {
get {
store.value(forKey: key) as? T ?? defaultValue
}
set {
store.setValue(newValue, forKey: key)
}
}
//Question: Should this be one shared binding or should it be a new binding on every get?
var projectedValue: Binding<T> {
Binding(
get: { self.wrappedValue },
set: { newValue in self.wrappedValue = newValue }
)
}
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
if context == &keyChangedContext,
keyPath == self.key {
print("\(keyPath) changed value")
//++++++++++++++++++++++++
//TODO: Somehow get the binding to send the value and trigger the DynamicProperty updating.
//++++++++++++++++++++++++
return
}
super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context)
}
}
struct Model {
@Storage(wrappedValue: true, "Test") var isEnabled
}
struct MyView: View {
init(_ model: Model) {
self._model = .init(initialValue: model)
}
@State var model: Model
@Storage(wrappedValue: true, "Test") var valueFromPreferences
//@Binding var valueFromModel: Bool
var body: some View {
Form {
Toggle("State", isOn: model.$isEnabled)
Toggle("Preferences", isOn: $valueFromPreferences)
}
}
}
PlaygroundPage.current.setLiveView(MyView(Model()))
This is my current version, it almost works, the main issue is cycle detected through attribute
which I think is happening because both AppStorage and the Model are both sending updates.
class Model: ObservableObject {
@UserDefault(wrappedValue: true, "Test") var isEnabledProperty
var isEnabled: Bool = false
{
didSet {
if _isEnabledProperty.wrappedValue != isEnabled {
_isEnabledProperty.wrappedValue = isEnabled
}
}
}
private var cancellable: Cancellable? = nil
init() {
cancellable = NotificationCenter.default
.publisher(for: UserDefaults.didChangeNotification)
.map { _ in () }
.debounce(for: .milliseconds(150), scheduler: RunLoop.main)
.sink(receiveValue: {
let currentValue = self._isEnabledProperty.wrappedValue
if self.isEnabled != currentValue {
self.isEnabled = currentValue
self.objectWillChange.send()
}
})
}
}
Ok, this works as expected, and plays nice with @AppStorage...
import Foundation
import SwiftUI
import Combine
import PlaygroundSupport
@propertyWrapper
public struct UserDefault<T> {
let key: String
let defaultValue: T
public init(wrappedValue: T, _ key: String) {
self.key = key
self.defaultValue = wrappedValue
}
public var wrappedValue: T {
get {
UserDefaults.standard.object(forKey: key) as? T ?? defaultValue
}
set {
UserDefaults.standard.set(newValue, forKey: key)
}
}
}
class Model: ObservableObject {
@UserDefault(wrappedValue: true, "Test") var isEnabled
public let objectWillChange = PassthroughSubject<Void, Never>()
private var cancellable: Cancellable? = nil
init() {
cancellable = NotificationCenter.default
.publisher(for: UserDefaults.didChangeNotification)
.map { _ in () }
.debounce(for: .milliseconds(150), scheduler: RunLoop.main)
.subscribe(objectWillChange)
}
}
struct MyView: View {
@StateObject var model = Model()
@AppStorage(wrappedValue: true, "Test") var isEnabledProperty
var body: some View {
Form {
Toggle("State", isOn: $model.isEnabled)
Toggle("Preferences", isOn: $isEnabledProperty)
}
}
}
PlaygroundPage.current.setLiveView(MyView())
Thanks a lot! This works, but I see a few issues:
- The model is now a
class
, and for reasons that are out of scope here, that's not a good option for me. I am looking for extending a struct with a property, without making additional changes to the model. - Observing the UserDefaults with a catch-all-notification triggers too often. You debounce it, but it still triggers a lot on unrelated changes. It'd be better to observe the key instead of the whole defaults.
ObservableObject
already has a defaultobjectWillChange
publisher that does not need to be overridden.
I went for a different approach in the meantime:
- I am still using a property wrapper, but that wraps the original AppStorage.
- In the View, I have to add the AppStorage as a View property that can trigger the update. This way I get around having to turn the struct into an observableObject class.
- I am using the projectedValue for getting the
AppStorage
.
import Foundation
import SwiftUI
import Combine
import PlaygroundSupport
@propertyWrapper
struct WrappedAppStorage<T> {
init(wrappedValue defaultValue: T,
_ key: String,store: UserDefaults = UserDefaults.standard) where T == Bool {
self.appStorage = AppStorage<T>(wrappedValue: defaultValue, key, store: store)
}
let appStorage: AppStorage<T>
var wrappedValue: T {
get { appStorage.wrappedValue }
set { appStorage.wrappedValue = newValue }
}
var projectedValue: AppStorage<T> { appStorage }
}
struct Model {
@WrappedAppStorage(wrappedValue: true, "Test") var isEnabled
}
struct MyView: View {
init(_ model: Model) {
self._model = .init(initialValue: model)
_valueFromModel = model.$isEnabled
}
@State var model: Model
@AppStorage var valueFromModel: Bool
@AppStorage(wrappedValue: true, "Test") var valueFromPreferences
var body: some View {
Form {
Toggle("State", isOn: $valueFromModel)
Toggle("Preferences", isOn: $valueFromPreferences)
}
.animation(.default)
}
}
PlaygroundPage.current.setLiveView(MyView(Model()))
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
You can use your own property wrapper, for example: