/** * 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: 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 { return subject.eraseToAnyPublisher() } private let key: String private let userDefaults: UserDefaults private var observerContext = 0 private let subject: CurrentValueSubject 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) } }