A modern Swift library for reading & writing app preferences:
- simple but powerful declarative DSL
- swappable/mockable storage backend (UserDefaults, Dictionary, PList file, and more)
- keys are implicitly
@Observable
and@Bindable
for effortless integration in modern SwiftUI apps - built from the ground up for Swift 6
- Add PrefsKit to your app or package
- Create a schema that defines the backing storage and preference key/value types.
- Apply the
@PrefsSchema
attribute to the class. - Define your
storage
andstorageMode
using the corresponding@Storage
and@StorageMode
attributes. - Use the
@Pref
attribute to declare individual preference keys and their value type. Value types may beOptional
or have a default value.
import Foundation import PrefsKit @PrefsSchema final class Prefs { @Storage var storage = .userDefaults @StorageMode var storageMode = .cachedReadStorageWrite @Pref var foo: String? @Pref var bar: Int = 123 @Pref var bool: Bool = false @Pref var array: [String]? @Pref var dict: [String: Int]? }
- Apply the
Tip
For a list of available storage value types, see Storage Value Types.
- Instantiate the class in the appropriate scope. If you are defining application preferences, the
App
struct is a good place to store it. It may be passed into the environment so that any subview can access it.struct MyApp: App { @State private var prefs = Prefs() var body: some Scene { WindowGroup { ContentView() .environment(prefs) } } }
- The class is implicitly
@Observable
so its properties can trigger SwiftUI view updates and be used as bindings.struct ContentView: View { @Environment(Prefs.self) private var prefs var body: some View { @Bindable var prefs = prefs Text("String: \(prefs.foo ?? "Not yet set.")") Text("Int: \(prefs.bar)") Toggle("State", isOn: $prefs.bool) } }
These are the atomic value types supported:
Atomic Type | Usage | Description |
---|---|---|
String |
@Pref var x: String = "" |
An atomic String value |
Bool |
@Pref var x: Bool = true |
An atomic Bool value |
Int |
@Pref var x: Int = 1 |
An atomic Int value |
Double |
@Pref var x: Double = 1.0 |
An atomic Double value |
Float |
@Pref var x: Float = 1.0 |
An atomic Float value |
Data |
@Pref var x: Data = Data() |
An atomic Data value |
Array | @Pref var x: [String] = [] |
Array of a single atomic value type |
Array (Mixed) | @Pref var x: AnyPrefsArray = [] |
Array of a mixture of atomic value types |
Dictionary | @Pref var x: [String: String] = [:] |
Keyed by String with a single atomic value type |
Dictionary (Mixed) | @Pref var x: AnyPrefsDictionary = [:] |
Keyed by String with a mixture of atomic value types |
Note
Instead of [Any]
, the custom AnyPrefsArray
type ensures type safety for its elements.
Likewise, instead of [String: Any]
, the custom AnyPrefsDictionary
type ensures type safety for its key values.
For more complex scenarios, a @PrefsSchema
class can have its storage backend and/or storage mode set at class init.
One method is by way of type-erasure using the concrete type AnyPrefsStorage
and passing your storage of choice in.
@PrefsSchema final class Prefs {
@Storage var storage: AnyPrefsStorage
@StorageMode var storageMode: PrefsStorageMode
init(storage: any PrefsStorage, storageMode: PrefsStorageMode) {
self.storage = AnyPrefsStorage(storage)
self.storageMode = storageMode
}
}
Another method is by way of generics if, for example, you know the storage backend will always be a dictionary.
The benefit of this approach is that it gives access to type-specific members of the concrete storage type instead of only protocolized PrefsStorage
members.
@PrefsSchema final class Prefs {
@Storage var storage: DictionaryPrefsStorage
@StorageMode var storageMode: PrefsStorageMode
init(storage: DictionaryPrefsStorage, mode: PrefsStorageMode) {
self.storage = storage
storageMode = mode
}
}
Key names are synthesized from the var name unless specified:
@Pref var foo: String? // storage key name is "foo"
@Pref(key: "bar") var foo: String? // storage key name is "bar"
Alternative macros are available for more complex types such as:
Allows using a RawRepresentable
type with a RawValue
that is one of the supported atomic storage value types.
@PrefsSchema final class Prefs {
@RawRepresentablePref var fruit: Fruit?
}
enum Fruit: String {
case apple, banana, orange
}
Convenience to encode and decode any Codable
type as JSON using either Data
or String
raw storage.
@PrefsSchema final class Prefs {
// encode Device as JSON using Data storage
@JSONDataCodablePref var device: Device?
// encode UUID as JSON using String storage
@JSONStringCodablePref var deviceID: UUID? // UUID natively conforms to Codable
}
struct Device: Codable {
var name: String
var manufacturer: String
}
Supports custom value ←→ storage value encoding implementation.
It can be done inline:
@PrefsSchema final class Prefs {
@Pref(encode: { $0.absoluteString }, decode: { URL(string: $0) })
var url: URL?
}
Or if a coding implementation needs to be reused, it can be defined once and specified in each preference declaration:
@PrefsSchema final class Prefs {
@Pref(coding: .urlString) var foo: URL?
@Pref(coding: .urlString) var bar: URL?
}
struct URLStringPrefsCoding: PrefsCodable {
func encode(prefsValue: URL) -> String? {
prefsValue.absoluteString
}
func decode(prefsValue: String) -> URL? {
URL(string: prefsValue)
}
}
extension PrefsCoding where Self == URLStringPrefsCoding {
static var urlString: URLStringPrefsCoding { URLStringPrefsCoding() }
}
Note
The approach of defining a custom PrefsCodable
implementation is ideal when it is either:
- a type that you do not own (ie: from another framework), or
- when the type may have more than one possible encoding format, or
- a type whose encoding format has changed over time and multiple formats need to be maintained (ie: for legacy preferences migration)
If it is for a custom type that is one you own and there is only one encoding format for it, an alternative approach could be to conform it to Swift's Codable
instead and use @JSONDataCodablePref
or @JSONStringCodablePref
to store it.
In complex projects it may be necessary to access the prefs storage directly using preference key(s) that may only be known at runtime.
The storage property may be accessed directly using value(forKey:)
and setValue(forKey:to:)
methods.
Note that mutating storage directly does not inherit the @Observable
behavior of @Pref
-defined keys, and of course they cannot be directly used in a SwiftUI Binding / Bindable context.
@PrefsSchema final class Prefs {
// @Pref vars are Observable and Bindable in SwiftUI views
@Pref var foo: Int?
// `storage` access is NOT Observable or Bindable in SwiftUI views
func fruit(name: String) -> String? {
storage.value(forKey: "fruit-\(name)")
}
func setFruit(name: String, to newValue: String?) {
storage.setValue(forKey: "fruit-\(name)", to: newValue)
}
}
In consideration of the aforementioned drawbacks, it is ideal to whatever extent possible, have a prefs schema that contains root-level preference keys that are known at compile time. A possible alternative would be to create a root-level @Pref
key that contains an array or dictionary which can then be used for dynamic access. For example:
@PrefsSchema final class Prefs {
@Pref var fruit: [String: String] = [:]
// this method is an unnecessary proxy, but WILL be Observable
func fruit(name: String) -> String? {
fruit[name]
}
}
struct ContentView: View {
@Environment(Prefs.self) private var prefs
var body: some View {
Text("Apple's value: \(prefs.fruit["apple"] ?? "Missing."))
}
}
Because of internal protocol requirements, actors (such as @MainActor
) cannot be directly attached to the @PrefsSchema
class declaration.
@MainActor // <-- ❌ not possible
@PrefsSchema final class Prefs { /* ... */ }
Actors may, however, be attached to individual @Pref
preference declarations.
@PrefsSchema final class Prefs {
@Storage var storage = .userDefaults
@StorageMode var storageMode = .cachedReadStorageWrite
@MainActor // <-- ✅ possible
@Pref var foo: Int?
@Pref var bar: String?
}
-
Why?
We all know how it goes - whether you get a spark of an idea for a new app or have been working on a larger codebase - preferences are a necessary but rudimentary part of building an application. But they're not the main event. And so often the problem is solved by way of path of least resistance, which usually goes something like "just use
UserDefaults
and move on" or "@AppStorage
is good enough."The danger of this convenient low-hanging fruit is the tech debt it inevitably creates over time. As a project grows and changes shape, its needs increase, and its automated testing requirements broaden. By then, large portions of the codebase are tightly coupled with specific implementation details (ie:
UserDefaults
access) and refactors to allow modular preferences become increasingly daunting.So, tired of every project having a haphazard approach to handling preferences — and inspired by patterns in first-party Apple packages such as SwiftData — a one-stop shop solution was built. It's simple, powerful, and uses modern Swift language features to allow preferences to be declarative while hiding implementation details so you can get on with the important stuff - like building features users care about. It can be minimal so it's easy to set up for small projects, but it can also scale for projects with larger demands.
-
Why not just use
@AppStorage
?The 1st-party provided
@AppStorage
property wrapper is convenient and perfectly fine for small apps that do not require robust storage flexibility or prefs isolation / mocking for integration testing or unit testing.It also is fairly limited in the value types it supports. PrefsKit offers an easy to use, extensible blueprint for defining and using encoding strategies for any value type.
-
Why not just use SwiftData?
SwiftData is more oriented towards data models and user document content. It requires some adaptation and boilerplate to shoehorn it into the role of application preferences storage. It also has a somewhat steep learning curve and may contain more features than are necessary.
PrefsKit is purpose-built for preference storage.
-
Why not just use
UserDefaults
directly?For small apps this approach may be adequate. However it forms tight coupling to
UserDefaults
as a storage backend. This means automated integration testing can't as easily be performed with isolated/mocked preferences. Even if the approach of using separateUserDefaults
suites for testing is employed, the coupling makes changing storage backend in the future more time-intensive.PrefsKit adds the ability to swap out the storage backend at any time in the future, in addition to its easy to use, extensible blueprints for defining and using encoding strategies for value types.
Add the package to your project or Swift package using https://github.com/orchetect/PrefsKit
as the URL.
Note that PrefsKit makes use of Swift Macros and because of this, Xcode will prompt you to allow macros for this package. It will ask again any time a new release of the package is available and you update to it.
Coded by a bunch of 🐹 hamsters in a trench coat that calls itself @orchetect.
Licensed under the MIT license. See LICENSE for details.
Contributions are welcome. Feel free to post an Issue to discuss.