-
-
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
importthis 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.