Seamless SwiftUI + Observation + UserDefaults + iCloud Key-Value Store integration
ObservableDefaults
is a comprehensive Swift library that seamlessly integrates both UserDefaults
and NSUbiquitousKeyValueStore
(iCloud Key-Value Storage) with SwiftUI's Observation framework. It provides two powerful macros - @ObservableDefaults
for local UserDefaults management and @ObservableCloud
for cloud-synchronized data storage - that simplify data persistence by automatically associating declared properties with their respective storage systems. This enables precise and efficient responsiveness to data changes, whether they originate from within the app, externally, or across multiple devices.
Managing multiple UserDefaults keys and cloud-synchronized data in SwiftUI can lead to bloated code and increase the risk of errors. While @AppStorage simplifies handling single UserDefaults keys, it doesn't scale well for multiple keys, lacks cloud synchronization capabilities, or offer precise view updates. With the introduction of the Observation framework, there's a need for a comprehensive solution that efficiently bridges both local and cloud storage with SwiftUI's state management.
ObservableDefaults was created to address these challenges by providing a complete data persistence solution. It leverages macros to reduce boilerplate code and ensures that your SwiftUI views respond accurately to changes in both UserDefaults and iCloud data.
For an in-depth discussion on the limitations of @AppStorage and the motivation behind ObservableDefaults, you can read the full article on my blog.
Don't miss out on the latest updates and excellent articles about Swift, SwiftUI, Core Data, and SwiftData. Subscribe to Fatbobman's Swift Weekly and receive weekly insights and valuable content directly to your inbox.
- Dual Storage Support: Seamless integration with both
UserDefaults
andNSUbiquitousKeyValueStore
(iCloud) - SwiftUI Observation: Full integration with the SwiftUI Observation framework
- Automatic Synchronization: Properties automatically sync with their respective storage systems
- Cross-Device Sync: Cloud-backed properties automatically synchronize across user's devices
- Precise Notifications: Property-level change notifications, reducing unnecessary view updates
- Development Mode: Testing support without CloudKit container requirements
- Customizable Behavior: Fine-grained control through additional macros and parameters
- Custom Keys and Prefixes: Support for property-specific storage keys and global prefixes
- Codable Support: Complex data persistence for both local and cloud storage
You can add ObservableDefaults
to your project using Swift Package Manager:
- In Xcode, go to File > Add Packages...
- Enter the repository URL:
https://github.com/fatbobman/ObservableDefaults
- Select the package and add it to your project.
After importing ObservableDefaults
, you can annotate your class with @ObservableDefaults
to automatically manage UserDefaults
synchronization:
import ObservableDefaults
@ObservableDefaults
class Settings {
var name: String = "Fatbobman"
var age: Int = 20
}
observableDefaults-1_2024-10-09_14.54.53-1.mp4
This macro automatically:
- Associates the
name
andage
properties withUserDefaults
keys. - Listens for external changes to these keys and updates the properties accordingly.
- Notifies SwiftUI views of changes precisely, avoiding unnecessary redraws.
For cloud-synchronized data that automatically syncs across devices, use the @ObservableCloud
macro:
import ObservableDefaults
@ObservableCloud
class CloudSettings {
var number = 1
var color: Colors = .red
var style: FontStyle = .style1
}
observableCloud-demo1.mp4
This macro automatically:
- Associates properties with
NSUbiquitousKeyValueStore
for iCloud synchronization - Listens for external changes from other devices and updates properties accordingly
- Provides the same precise SwiftUI observation as
@ObservableDefaults
- Supports development mode for testing without CloudKit container setup
Both @ObservableDefaults
and @ObservableCloud
classes work identically in SwiftUI views:
import SwiftUI
struct ContentView: View {
@State var settings = Settings() // UserDefaults-backed
@State var cloudSettings = CloudSettings() // iCloud-backed
var body: some View {
VStack {
// Local settings
Text("Name: \(settings.name)")
TextField("Enter name", text: $settings.name)
// Cloud-synchronized settings
Text("Username: \(cloudSettings.username)")
TextField("Enter username", text: $cloudSettings.username)
}
.padding()
}
}
The library provides additional macros for finer control:
@ObservableOnly
: The property is observable but not stored inUserDefaults
.@Ignore
: The property is neither observable nor stored inUserDefaults
.@DefaultsKey
: Specifies a customUserDefaults
key for the property.@DefaultsBacked
: The property is stored inUserDefaults
and observable.
@ObservableDefaults
public class LocalSettings {
@DefaultsKey(userDefaultsKey: "firstName")
public var name: String = "fat"
public var age = 109 // Automatically backed by UserDefaults
@ObservableOnly
public var height = 190 // Observable only, not persisted
@Ignore
public var weight = 10 // Neither observable nor persisted
}
Similar macro support with cloud-specific options:
@ObservableOnly
: The property is observable but not stored inNSUbiquitousKeyValueStore
.@Ignore
: The property is neither observable nor stored.@CloudKey
: Specifies a customNSUbiquitousKeyValueStore
key for the property.@CloudBacked
: The property is stored inNSUbiquitousKeyValueStore
and observable.
@ObservableCloud
public class CloudSettings {
@CloudKey(keyValueStoreKey: "user_display_name")
public var username: String = "Fatbobman"
public var theme: String = "light" // Automatically cloud-backed
@ObservableOnly
public var localCache: String = "" // Observable only, not synced to cloud
@Ignore
public var temporaryData: String = "" // Neither observable nor persisted
}
If all properties have default values, you can use the automatically generated initializer:
public init(
userDefaults: UserDefaults? = nil,
ignoreExternalChanges: Bool? = nil,
prefix: String? = nil
)
Parameters:
userDefaults
: TheUserDefaults
instance to use (default is.standard
).ignoreExternalChanges
: Iftrue
, the instance ignores externalUserDefaults
changes (default isfalse
).prefix
: A prefix for allUserDefaults
keys associated with this class.
The cloud version provides similar initialization options:
public init(
prefix: String? = nil,
syncImmediately: Bool = false,
developmentMode: Bool = false
)
Parameters:
prefix
: A prefix for allNSUbiquitousKeyValueStore
keys.syncImmediately
: Iftrue
, forces immediate synchronization after each change.developmentMode
: Iftrue
, uses memory storage instead of iCloud for testing.
// UserDefaults-backed settings
@State var settings = Settings(
userDefaults: .standard,
ignoreExternalChanges: false,
prefix: "myApp_"
)
// Cloud-backed settings
@State var cloudSettings = CloudSettings(
prefix: "myApp_",
syncImmediately: true,
developmentMode: false
)
You can set parameters directly in the @ObservableDefaults
macro:
userDefaults
: TheUserDefaults
instance to use.ignoreExternalChanges
: Whether to ignore external changes.prefix
: A prefix forUserDefaults
keys.autoInit
: Whether to automatically generate the initializer (default istrue
).observeFirst
: Observation priority mode (default isfalse
).
@ObservableDefaults(autoInit: false, ignoreExternalChanges: true, prefix: "myApp_")
class Settings {
@DefaultsKey(userDefaultsKey: "fullName")
var name: String = "Fatbobman"
}
The cloud macro provides similar configuration options:
autoInit
: Whether to automatically generate the initializer (default istrue
).prefix
: A prefix forNSUbiquitousKeyValueStore
keys.observeFirst
: Observation priority mode (default isfalse
).syncImmediately
: Whether to force immediate synchronization (default isfalse
).developmentMode
: Whether to use memory storage for testing (default isfalse
).
@ObservableCloud(
autoInit: true,
prefix: "myApp_",
observeFirst: false,
syncImmediately: true,
developmentMode: false
)
class CloudSettings {
@CloudKey(keyValueStoreKey: "user_theme")
var theme: String = "light"
}
The @ObservableCloud
macro supports development mode for testing without CloudKit setup:
@ObservableCloud(developmentMode: true)
class CloudSettings {
var setting1: String = "value1" // Uses memory storage
var setting2: Int = 42 // Uses memory storage
}
Development mode is automatically enabled when:
- Explicitly set via
developmentMode: true
- Running in SwiftUI Previews (
XCODE_RUNNING_FOR_PREVIEWS
environment variable) OBSERVABLE_DEFAULTS_DEV_MODE
environment variable is set to "true"
If you set autoInit
to false
for either macro, you need to create your own initializer:
// For @ObservableDefaults
init() {
observerStarter() // Start listening for UserDefaults changes
}
// For @ObservableCloud
init() {
// Start Cloud Observation only in production mode
if !_developmentMode_ {
_cloudObserver = CloudObservation(host: self, prefix: _prefix)
}
}
Both macros support "Observe First" mode, where properties are observable by default but only explicitly marked properties are persisted:
@ObservableDefaults(observeFirst: true)
public class LocalSettings {
public var name: String = "fat" // Observable only
public var age = 109 // Observable only
@DefaultsBacked(userDefaultsKey: "myHeight")
public var height = 190 // Observable and persisted to UserDefaults
@Ignore
public var weight = 10 // Neither observable nor persisted
}
@ObservableCloud(observeFirst: true)
public class CloudSettings {
public var localSetting: String = "local" // Observable only
public var tempData = "temp" // Observable only
@CloudBacked(keyValueStoreKey: "user_theme")
public var theme: String = "light" // Observable and synced to iCloud
@Ignore
public var cache = "cache" // Neither observable nor persisted
}
Both macros support properties conforming to Codable
for complex data persistence:
@ObservableDefaults
class LocalStore {
var people: People = .init(name: "fat", age: 10)
}
struct People: CodableUserDefaultsPropertyListValue {
var name: String
var age: Int
}
@ObservableCloud
class CloudStore {
var userProfile: UserProfile = .init(name: "fat", preferences: .init())
}
struct UserProfile: CodableCloudPropertyListValue {
var name: String
var preferences: UserPreferences
}
struct UserPreferences: Codable {
var theme: String = "light"
var fontSize: Int = 14
}
It's recommended to manage storage data separately from your main application state:
@Observable
class ViewState {
var selection = 10
var isLogin = false
let localSettings = LocalSettings() // UserDefaults-backed
let cloudSettings = CloudSettings() // iCloud-backed
}
struct ContentView: View {
@State var state = ViewState()
var body: some View {
VStack(spacing: 30) {
// Local settings
Text("Local Name: \(state.localSettings.name)")
Button("Modify Local Setting") {
state.localSettings.name = "User \(Int.random(in: 0...1000))"
}
// Cloud settings
Text("Cloud Username: \(state.cloudSettings.username)")
Button("Modify Cloud Setting") {
state.cloudSettings.username = "CloudUser \(Int.random(in: 0...1000))"
}
}
.buttonStyle(.bordered)
}
}
All persistent properties (those marked with @DefaultsBacked or @CloudBacked, either explicitly or implicitly) must be declared with default values. The framework captures these declaration-time defaults and maintains them as immutable fallback values throughout the object's lifetime. When keys are missing from the underlying storage (UserDefaults or iCloud Key-Value Store), properties automatically revert to these preserved default values, ensuring consistent behavior regardless of external storage modifications.
@ObservableDefaults(autoInit: false) // @ObservableCloud(autoInit: false) is the same
class User {
var username = "guest" // ← Declaration default: "guest"
var age: Int = 18 // ← Declaration default: 18
init(username: String, age: Int) {
self.username = username // Current value: "alice", default remains: "guest"
self.age = age // Current value: 25, default remains: 18
// ... other initialization code, like observerStarter(observableKeysBlacklist: [])
}
}
let user = User(username: "alice", age: 25)
// Current state:
// - username current value: "alice"
// - username default value: "guest" (immutable)
// - age current value: 25
// - age default value: 18 (immutable)
user.username = "bob" // Changes current value, default value stays "guest"
// If UserDefaults keys are deleted externally:
UserDefaults.standard.removeObject(forKey: "username")
UserDefaults.standard.removeObject(forKey: "age")
print(user.username) // "guest" (reverts to declaration default)
print(user.age) // 18 (reverts to declaration default)
Recommendation: Unless you have specific requirements, use
autoInit: true
(default) to generate the standard initializer automatically. This helps avoid the misconception that default values can be modified through custom initializers.
- External Changes: By default, both macros respond to external changes in their respective storage systems.
- Key Prefixes: Use the
prefix
parameter to prevent key collisions when multiple classes use the same property names. - Custom Keys: Use
@DefaultsKey
or@CloudKey
to specify custom keys for properties. - Prefix Characters: The prefix must not contain '.' characters.
- iCloud Account: Cloud storage requires an active iCloud account and network connectivity.
- Storage Limits:
NSUbiquitousKeyValueStore
has a 1MB total storage limit and 1024 key limit. - Synchronization: Changes may take time to propagate across devices depending on network conditions.
- Development Mode: Use development mode for testing without CloudKit container setup.
- Data Migration: Changing property names or custom keys after deployment may cause cloud data to become inaccessible.
- Direct NSUbiquitousKeyValueStore Modifications: Directly modifying values using
NSUbiquitousKeyValueStore.default.set()
will not trigger local property updates in ObservableCloud classes. This is due to NSUbiquitousKeyValueStore's communication mechanism, which does not send notifications for local modifications. Always modify properties through the ObservableCloud instance to ensure proper synchronization and view updates.
ObservableDefaults
is released under the MIT License. See LICENSE for details.
Special thanks to the Swift community for their continuous support and contributions.