-
-
Save simonbs/61c8269e1b0550feab606ee9890fa72b to your computer and use it in GitHub Desktop.
/** | |
* 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) | |
} | |
} | |
var projectedValue: AnyPublisher<T, Never> { | |
return subject.eraseToAnyPublisher() | |
} | |
private let key: String | |
private let userDefaults: UserDefaults | |
private var observerContext = 0 | |
private let subject: CurrentValueSubject<T, Never> | |
init(wrappedValue defaultValue: T, _ key: String, userDefaults: UserDefaults = .standard) { | |
self.key = key | |
self.userDefaults = userDefaults | |
self.subject = 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) | |
subject.value = wrappedValue | |
} | |
override func observeValue( | |
forKeyPath keyPath: String?, | |
of object: Any?, | |
change: [NSKeyValueChangeKey : Any]?, | |
context: UnsafeMutableRawPointer?) { | |
if context == &observerContext { | |
subject.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) | |
} | |
} |
Hi @simonbs, it seems that updating the UserDefaults directly (i.e. UserDefaults.standard.set(_ value: Any?, forKey
) does result in publish events, but somehow it does not cause the SwiftUI toggle to update, as I had hoped.
Do you know if that is possible to do from within the property wrapper?
The use case I'm going for is settings that can be synchronized between a watchOS app and iOS app.
I think I've gotten closer to the solution I'm looking for:
class Settings: ObservableObject {
@UserDefault("profileName") var profileName = "Default Name"
private var listeners = Set<AnyCancellable>()
init() {
$profileName.sink { _ in self.objectWillChange.send() }.store(in: &listeners)
}
}
Now if I could just figure out how to access the objectWillChange publisher from within the property wrapper, I'd be set!
doesn't work at all + you don't specify import
this code needs
Cool, works just fine as expected. Thanx.
doesn't work at all + you don't specify
import
this code needs
you need import Combine
to the UserDefault class.
@Muhammadbarznji it seems the problem is with simulator - it doesn't apply changes immediately and you need to wait for some time
@frankschlegel That's clever! Thanks! I've updated the gist (and my codebase) to include this.