Skip to content

Instantly share code, notes, and snippets.

@multitudes
Forked from simonbs/UserDefault.swift
Created May 1, 2021 12:43
Show Gist options
  • Save multitudes/3881ed9dbaa6fc4de755b7ef9edc26a1 to your computer and use it in GitHub Desktop.
Save multitudes/3881ed9dbaa6fc4de755b7ef9edc26a1 to your computer and use it in GitHub Desktop.
Property wrapper that stores values in UserDefaults and works with SwiftUI and Combine.
/**
* I needed a property wrapper that fulfilled the following four requirements:
*
* 1. Values are stored in UserDefaults.
* 2. Properties using the property wrapper can be used with SwiftUI.
* 3. The property wrapper exposes a Publisher to be used with Combine.
* 4. The publisher is only called when the value is updated and not
* when_any_ value stored in UserDefaults is updated.
*
* First I tried using SwiftUI's builtin @AppStorage property wrapper
* but this doesn't provide a Publisher to be used with Combine.
*
* So I posted a tweet asking people how I can go about creating my own property wrapper:
* https://twitter.com/simonbs/status/1387648636352348160
*
* A lot people replied but I didn't find a solution that was exactly what I wanted. Many suggestions came close
* and based on those suggestions, I have implemented the property wrapper below.
*
* The main downside of this property wrapper is that it inherits from NSObject.
* That's not very Swift-y but I can live wit that.
*/
// This is our property wrapper. Other types in this gist is just example usages of the property wrapper.
// The type inherits from NSObject to do old-fashined KVO without the KeyPath type.
//
// For simplicity sake the type in this gist only supports property list objects but can easily be combined
// with an approach similar to the one Jesse Squires takes in their Foil framework to support any type:
// https://github.com/jessesquires/Foil
@propertyWrapper
final class UserDefault<T>: NSObject {
// This ensures requirement 1 is fulfilled. The wrapped value is stored in user defaults.
var wrappedValue: T {
get {
return userDefaults.object(forKey: key) as! T
}
set {
userDefaults.setValue(newValue, forKey: key)
}
}
private(set) var projectedValue: CurrentValueSubject<T, Never>
private let key: String
private let userDefaults: UserDefaults
private var observerContext = 0
init(wrappedValue defaultValue: T, _ key: String, userDefaults: UserDefaults = .standard) {
self.key = key
self.userDefaults = userDefaults
self.projectedValue = CurrentValueSubject(defaultValue)
super.init()
userDefaults.register(defaults: [key: defaultValue])
// This fulfills requirement 4. Some implementations use NSUserDefaultsDidChangeNotification
// but that is sent every time any value is updated in UserDefaults.
userDefaults.addObserver(self, forKeyPath: key, options: .new, context: &observerContext)
projectedValue.value = wrappedValue
}
override func observeValue(
forKeyPath keyPath: String?,
of object: Any?,
change: [NSKeyValueChangeKey : Any]?,
context: UnsafeMutableRawPointer?) {
if context == &observerContext {
projectedValue.value = wrappedValue
} else {
super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context)
}
}
deinit {
userDefaults.removeObserver(self, forKeyPath: key, context: &observerContext)
}
}
// Holds a reference to all the values we store in UserDefaults. This isn't necessary but once you start
// having a lot of preferences in your app, you'll probably want to have those in a single place.
struct Preferences {
private enum Key {
static let isLineWrappingEnabled = "isLineWrappingEnabled"
}
@UserDefault(Preferences.Key.isLineWrappingEnabled) var isLineWrappingEnabled = true
}
// This proves that requirement 3 is fulfilled. We can use properties with Combine.
final class PreferencesViewModel: ObservableObject {
@Published var preferences = Preferences()
private var lineWrappingCancellable: AnyCancellable?
init() {
lineWrappingCancellable = preferences.$isLineWrappingEnabled.sink { isEnabled in
print(isEnabled)
}
}
}
// This proves that requirement 2 is fulfilled. We can use properties in SwiftUI.
struct PreferencesView: View {
@ObservedObject private var viewModel: PreferencesViewModel
var body: some View {
Toggle("Enable Line Wrapping", isOn: $viewModel.preferences.isLineWrappingEnabled)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment