Skip to content

Instantly share code, notes, and snippets.

@simonbs
Last active August 2, 2025 16:09
Show Gist options
  • Select an option

  • Save simonbs/61c8269e1b0550feab606ee9890fa72b to your computer and use it in GitHub Desktop.

Select an option

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
Copy Markdown

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
Copy Markdown
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
Copy Markdown

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
Copy Markdown

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

@pktealshift
Copy link
Copy Markdown

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
Copy Markdown

Gargo commented Jun 21, 2023

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

@Vanyaslav
Copy link
Copy Markdown

Cool, works just fine as expected. Thanx.

@Muhammadbarznji
Copy link
Copy Markdown

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

you need import Combine to the UserDefault class.

@Gargo
Copy link
Copy Markdown

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