Skip to content

Instantly share code, notes, and snippets.

@simonbs
Last active March 3, 2024 11:38
Show Gist options
  • Star 30 You must be signed in to star a gist
  • Fork 5 You must be signed in to fork a gist
  • Save simonbs/61c8269e1b0550feab606ee9890fa72b to your computer and use it in GitHub Desktop.
Save simonbs/61c8269e1b0550feab606ee9890fa72b 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)
}
}
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)
}
}
@frankschlegel
Copy link

Looks good!
Just keep in mind that by exposing the CurrentValueSubject directly you can't prevent anyone from triggering an update on the subject from the outside: $myUserDefaultsVar.value = 42.

You could instead expose the subject as a Publisher:

private var subject: CurrentValueSubject<T, Never>

var projectedValue: AnyPublisher<T, Never> {
    return self.subject.eraseToAnyPublisher()
}

@simonbs
Copy link
Author

simonbs commented May 1, 2021

@frankschlegel That's clever! Thanks! I've updated the gist (and my codebase) to include this.

@pktealshift
Copy link

pktealshift commented Nov 29, 2022

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?

@pktealshift
Copy link

The use case I'm going for is settings that can be synchronized between a watchOS app and iOS app.

@pktealshift
Copy link

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!

@Gargo
Copy link

Gargo commented Jun 21, 2023

doesn't work at all + you don't specify import this code needs

@Vanyaslav
Copy link

Cool, works just fine as expected. Thanx.

@Muhammadbarznji
Copy link

doesn't work at all + you don't specify import this code needs

you need import Combine to the UserDefault class.

@Gargo
Copy link

Gargo commented Jul 25, 2023

@Muhammadbarznji it seems the problem is with simulator - it doesn't apply changes immediately and you need to wait for some time

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment