Skip to content

Instantly share code, notes, and snippets.

@aheze
Created May 27, 2022 15:35
Show Gist options
  • Save aheze/a895c39052343e3d3703c977b6ba815d to your computer and use it in GitHub Desktop.
Save aheze/a895c39052343e3d3703c977b6ba815d to your computer and use it in GitHub Desktop.
App Storage with `objectWillChange` support
/// based on https://github.com/xavierLowmiller/AppStorage
/// A property wrapper type that reflects a value from `UserDefaults` and
/// invalidates a view on a change in value in that user default.
@frozen @propertyWrapper public struct Saved<Value>: DynamicProperty {
@ObservedObject private var _value: Storage<Value>
let saveValue: (Value) -> Void
let key: String
var valueChanged: (() -> Void)?
private init(value: Value, store: UserDefaults, key: String, transform: @escaping (Any?) -> Value?, saveValue: @escaping (Value) -> Void) {
self._value = Storage(value: value, store: store, key: key, transform: transform)
self.saveValue = saveValue
self.key = key
}
public var wrappedValue: Value {
get {
_value.value
}
nonmutating set {
saveValue(newValue)
_value.value = newValue
if let valueChanged = valueChanged {
valueChanged() /// let the parent view model know the value changed
} else {
assertionFailure("`valueChanged` not set for \(self)") /// otherwise, crash the app.
}
}
}
public var projectedValue: Binding<Value> {
Binding(
get: { self.wrappedValue },
set: { self.wrappedValue = $0 }
)
}
}
@usableFromInline
final class Storage<Value>: NSObject, ObservableObject {
@Published var value: Value
private let defaultValue: Value
private let store: UserDefaults
private let keyPath: String
private let transform: (Any?) -> Value?
init(value: Value, store: UserDefaults, key: String, transform: @escaping (Any?) -> Value?) {
self.value = value
self.defaultValue = value
self.store = store
self.keyPath = key
self.transform = transform
super.init()
store.addObserver(self, forKeyPath: key, options: [.new], context: nil)
}
deinit {
store.removeObserver(self, forKeyPath: keyPath)
}
override func observeValue(
forKeyPath keyPath: String?,
of object: Any?,
change: [NSKeyValueChangeKey: Any]?,
context: UnsafeMutableRawPointer?
) {
value = change?[.newKey].flatMap(transform) ?? defaultValue
}
}
public extension Saved where Value == Bool {
/// Creates a property that can read and write to a boolean user default.
///
/// - Parameters:
/// - wrappedValue: The default value if a boolean value is not specified
/// for the given key.
/// - key: The key to read and write the value to in the user defaults
/// store.
/// - store: The user defaults store to read and write to. A value
/// of `nil` will use the user default store from the environment.
init(wrappedValue: Value, _ key: String, store: UserDefaults? = nil) {
let store = (store ?? .standard)
let initialValue = store.value(forKey: key) as? Value ?? wrappedValue
self.init(value: initialValue, store: store, key: key, transform: {
$0 as? Value
}, saveValue: { newValue in
store.setValue(newValue, forKey: key)
})
}
}
public extension Saved where Value == Int {
/// Creates a property that can read and write to an integer user default.
///
/// - Parameters:
/// - wrappedValue: The default value if an integer value is not specified
/// for the given key.
/// - key: The key to read and write the value to in the user defaults
/// store.
/// - store: The user defaults store to read and write to. A value
/// of `nil` will use the user default store from the environment.
init(wrappedValue: Value, _ key: String, store: UserDefaults? = nil) {
let store = (store ?? .standard)
let initialValue = store.value(forKey: key) as? Value ?? wrappedValue
self.init(value: initialValue, store: store, key: key, transform: {
$0 as? Value
}, saveValue: { newValue in
store.setValue(newValue, forKey: key)
})
}
}
class StorageModel: ObservableObject {
static let data = StorageModelData.self
@Saved(data.defaultTab.key) var defaultTab = data.defaultTab.value
@Saved(data.swipeToNavigate.key) var swipeToNavigate = data.swipeToNavigate.value
init() {
/// start listening to the `valueChanged` closure
_defaultTab.configureValueChanged(with: self)
_swipeToNavigate.configureValueChanged(with: self)
}
}
/// contains all the default keys and values
enum StorageModelData {
static var defaultTab = SavedData(key: "defaultTab", value: 2)
static var swipeToNavigate = SavedData(key: "swipeToNavigate", value: true)
}
extension Saved {
/// listen to value changed
mutating func configureValueChanged(with model: StorageModel) {
let key = self.key
valueChanged = { [weak model] in
model?.objectWillChange.send()
NotificationCenter.default.post(name: Notification.Name(key), object: nil)
}
}
}
extension NSObject {
/// listen to a Storage Model Defaults notification, calling the selector at first too
/// Combine's `sink` does not work on the custom property wrapper, so I had to use notification center as a workaround.
func listen(to key: String, selector: Selector) {
NotificationCenter.default.addObserver(self, selector: selector, name: NSNotification.Name(key), object: nil)
perform(selector)
}
}
extension ViewController {
func listenToDefaults() {
/// listens to the notification center (Sombine's sink doesn't work on `Saved`)
self.listen(to: StorageModelData.swipeToNavigate.key, selector: #selector(self.swipeToNavigateChanged))
}
@objc func swipeToNavigateChanged() {
self.contentCollectionView.isScrollEnabled = self.getScrollViewEnabled()
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment