Compare commits

...

6 commits

7 changed files with 355 additions and 47 deletions

View file

@ -10,21 +10,24 @@ let package = Package(
.macOS(.v11),
.macCatalyst(.v14),
.tvOS(.v14),
.watchOS(.v7)
.watchOS(.v7),
],
products: [
// Products define the executables and libraries a package produces, making them visible to other packages.
.library(
name: "TypedAppStorage",
targets: ["TypedAppStorage"]),
targets: ["TypedAppStorage"]
)
],
targets: [
// Targets are the basic building blocks of a package, defining a module or a test suite.
// Targets can depend on other targets in this package and products from dependencies.
.target(
name: "TypedAppStorage"),
name: "TypedAppStorage"
),
.testTarget(
name: "TypedAppStorageTests",
dependencies: ["TypedAppStorage"]),
dependencies: ["TypedAppStorage"]
),
]
)

View file

@ -1,9 +1,9 @@
# ``TypedAppStorage``
A type-safe way to save and read complex data structures from `@AppStorage`.
A type-safe way to save and read complex data structures from `@AppStorage` and `UserDefaults`.
- Use actual `@AppStorage` underneath
- Support any `Codable` data
- Use actual `@AppStorage` or `UserDefaults` underneath
- Support any `Codable & Sendable` data
- Define the key in the data model
## Topics
@ -11,5 +11,6 @@ A type-safe way to save and read complex data structures from `@AppStorage`.
### Essentials
- <doc:GettingStarted>
- ``TypedAppStorage``
- ``TypedAppStorageValue``
- ``TypedAppStorage``
- ``TypedUserDefaults``

View file

@ -69,3 +69,46 @@ In some cases it might make sense to specify a different default value than the
```
And you can specify a different store just like `@AppStorage`, if you're using things like App Groups.
### Use outside SwiftUI Views
When you're not in a SwiftUI context, you can use ``TypedUserDefaults`` to access the same type-safe storage:
```swift
let typedDefaults = TypedUserDefaults.standard
// Reading values
let currentFruit = typedDefaults.object(of: PreferredFruit.self)
// Writing values
let newFruit = PreferredFruit(.moderate, .pear)
typedDefaults.set(newFruit)
```
Just like `@TypedAppStorage`, the key and default value are automatically inferred from the type's ``TypedAppStorageValue`` conformance.
You can also override the key or default value if needed:
```swift
// Use a different key
let specialFruit = typedDefaults.object(
of: PreferredFruit.self,
forKey: "specialPreferredFruit"
)
// Use a different default value
let fruitWithCustomDefault = typedDefaults.object(
of: PreferredFruit.self,
defaultValue: .init(.somewhatStale, .banana)
)
```
And you can use a different UserDefaults store, just like with the SwiftUI property wrapper:
```swift
let groupDefaults = TypedUserDefaults(
userDefaults: UserDefaults(suiteName: "group.com.example.app")!
)
let sharedFruit = groupDefaults.object(of: PreferredFruit.self)
```

View file

@ -3,7 +3,7 @@ import SwiftUI
/// The protocol that typed app storage values must conform to.
///
/// The most important requirement is conformance to `Codable`. Use ``TypedAppStorage`` in SwiftUI views to store and fetch conforming data.
public protocol TypedAppStorageValue: Codable {
public protocol TypedAppStorageValue: Codable, Sendable {
/// The actual key under which this type of data is stored.
static var appStorageKey: String { get }
/// The default value to return, if there's no data under the specified ``appStorageKey``.
@ -18,39 +18,49 @@ public protocol TypedAppStorageValue: Codable {
public struct TypedAppStorage<Value: TypedAppStorageValue>: DynamicProperty {
private var appStorage: AppStorage<String>
private var initialValue: Value
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
/// Store and fetch value from the defined store, with a predefined default value.
///
/// This default value if defined is preferred over ``TypedAppStorageValue/defaultValue``.
public init(wrappedValue: Value, store: UserDefaults? = nil) {
initialValue = wrappedValue
let initialData = try? JSONEncoder().encode(wrappedValue)
let initialString = (initialData == nil ? nil : String(data: initialData!, encoding: .utf8)) ?? ""
appStorage = .init(wrappedValue: initialString, Value.appStorageKey, store: store)
let initialData = try? encoder.encode(wrappedValue)
let initialString =
(initialData == nil ? nil : String(data: initialData!, encoding: .utf8))
?? ""
appStorage = .init(
wrappedValue: initialString,
Value.appStorageKey,
store: store
)
}
/// Store and fetch value from the defined store.
///
/// ``TypedAppStorageValue/defaultValue`` is used if no value was previously saved.
public init(store: UserDefaults? = nil) {
self.init(wrappedValue: Value.defaultValue, store: store)
}
/// The wrapped ``TypedAppStorageValue``.
public var wrappedValue: Value {
get {
guard let data = appStorage.wrappedValue.data(using: .utf8) else { return initialValue }
return (try? JSONDecoder().decode(Value.self, from: data)) ?? initialValue
guard let data = appStorage.wrappedValue.data(using: .utf8) else {
return initialValue
}
return (try? decoder.decode(Value.self, from: data)) ?? initialValue
}
nonmutating set {
guard
let newData = try? JSONEncoder().encode(newValue),
let newData = try? encoder.encode(newValue),
let newString = String(data: newData, encoding: .utf8)
else { return }
appStorage.wrappedValue = newString
}
}
/// A two-way binding of ``wrappedValue``.
public var projectedValue: Binding<Value> {
.init {

View file

@ -0,0 +1,91 @@
import Foundation
/// A type-safe wrapper around UserDefaults that supports JSON-based Codable encoding/decoding.
///
/// `TypedUserDefaults` mimics Apple's UserDefaults API while providing type safety for complex data structures
/// that conform to ``TypedAppStorageValue``. Since ``TypedAppStorageValue`` includes the storage key and default value,
/// many parameters become optional in the API.
public class TypedUserDefaults {
/// The underlying UserDefaults store.
public let userDefaults: UserDefaults
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
/// The shared typed user defaults object.
public static let standard = TypedUserDefaults()
/// Creates a TypedUserDefaults instance with the specified UserDefaults store.
/// - Parameter userDefaults: The underlying UserDefaults store. Defaults to UserDefaults.standard.
public init(userDefaults: UserDefaults = .standard) {
self.userDefaults = userDefaults
}
// MARK: - Reading Values
/// Returns the typed object associated with the specified key.
/// - Parameters:
/// - type: The type of object to retrieve.
/// - key: The key to retrieve the value for. If nil, uses the type's appStorageKey.
/// - defaultValue: The default value to return if no object exists for the key. If nil, uses the type's default value.
/// - Returns: The typed object, or the type's default value if no object exists for the key.
public func object<T: TypedAppStorageValue>(
of type: T.Type,
forKey key: String? = nil,
defaultValue: T? = nil
) -> T {
let storageKey = key ?? T.appStorageKey
guard let jsonString = userDefaults.string(forKey: storageKey),
let data = jsonString.data(using: .utf8),
let decodedValue = try? decoder.decode(T.self, from: data)
else {
return defaultValue ?? T.defaultValue
}
return decodedValue
}
// MARK: - Writing Values
/// Sets the value of the specified typed object for the given key.
/// - Parameters:
/// - value: The typed object to store.
/// - key: The key to store the value under. If nil, uses the type's appStorageKey.
public func set<T: TypedAppStorageValue>(
_ value: T,
forKey key: String? = nil
) {
let storageKey = key ?? T.appStorageKey
guard let data = try? encoder.encode(value),
let jsonString = String(data: data, encoding: .utf8)
else {
return
}
userDefaults.set(jsonString, forKey: storageKey)
}
// MARK: - Removing Values
/// Removes the value associated with the specified key for the given type.
/// - Parameters:
/// - type: The type of object to remove.
/// - key: The key to remove the value for. If nil, uses the type's appStorageKey.
public func removeObject<T: TypedAppStorageValue>(
of type: T.Type,
forKey key: String? = nil
) {
let storageKey = key ?? T.appStorageKey
userDefaults.removeObject(forKey: storageKey)
}
// MARK: - UserDefaults Forwarding
/// Synchronizes any changes made to the shared user defaults.
/// - Returns: true if the synchronization was successful, false otherwise.
@discardableResult
public func synchronize() -> Bool {
return userDefaults.synchronize()
}
}

View file

@ -1,5 +1,6 @@
import XCTest
import SwiftUI
import Testing
@testable import TypedAppStorage
struct PreferredFruit: TypedAppStorageValue, Equatable {
@ -9,13 +10,13 @@ struct PreferredFruit: TypedAppStorageValue, Equatable {
enum Freshness: Codable {
case veryFresh, moderate, somewhatStale
}
static var appStorageKey = "preferredFruit"
static var defaultValue = PreferredFruit(.veryFresh, .apple)
var fruit: Fruit
var freshness: Freshness
init(_ freshness: Freshness, _ fruit: Fruit) {
self.fruit = fruit
self.freshness = freshness
@ -24,11 +25,11 @@ struct PreferredFruit: TypedAppStorageValue, Equatable {
struct TestArticle: View {
@TypedAppStorage var preferredFruit: PreferredFruit
func changePreferred(to newValue: PreferredFruit) {
preferredFruit = newValue
}
var body: some View {
Text("Test")
}
@ -36,47 +37,59 @@ struct TestArticle: View {
struct TestArticleWithADifferentDefault: View {
@TypedAppStorage var preferredFruit: PreferredFruit = .init(.moderate, .pear)
func changePreferred(to newValue: PreferredFruit) {
preferredFruit = newValue
}
var body: some View {
Text("Test")
}
}
final class TypedAppStorageTests: XCTestCase {
override func setUp() {
@Suite("TypedAppStorage Tests", .serialized)
struct TypedAppStorageTests {
init() {
UserDefaults.standard.removeObject(forKey: "preferredFruit")
}
func testReadDefaultValue() throws {
@Test("Read default value")
func readDefaultValue() throws {
UserDefaults.standard.removeObject(forKey: "preferredFruit")
let testArticle = TestArticle()
XCTAssertEqual(testArticle.preferredFruit, PreferredFruit(.veryFresh, .apple))
#expect(
testArticle.preferredFruit == PreferredFruit(.veryFresh, .apple)
)
}
func testCallSiteDefault() throws {
@Test("Call-site default")
func callSiteDefault() throws {
UserDefaults.standard.removeObject(forKey: "preferredFruit")
let testArticle = TestArticleWithADifferentDefault()
XCTAssertEqual(testArticle.preferredFruit, .init(.moderate, .pear))
#expect(testArticle.preferredFruit == .init(.moderate, .pear))
}
func testSaveAndReadBack() throws {
@Test("Save and read back")
func saveAndReadBack() throws {
UserDefaults.standard.removeObject(forKey: "preferredFruit")
let testArticle = TestArticle()
testArticle.changePreferred(to: .init(.somewhatStale, .banana))
XCTAssertEqual(testArticle.preferredFruit, .init(.somewhatStale, .banana))
#expect(testArticle.preferredFruit == .init(.somewhatStale, .banana))
}
func testSaveAndReadElsewhere() throws {
@Test("Save and read elsewhere")
func saveAndReadElsewhere() throws {
UserDefaults.standard.removeObject(forKey: "preferredFruit")
let articleA = TestArticle()
let articleB = TestArticle()
articleA.changePreferred(to: .init(.moderate, .banana))
XCTAssertEqual(articleB.preferredFruit, .init(.moderate, .banana))
#expect(articleB.preferredFruit == .init(.moderate, .banana))
}
}

View file

@ -0,0 +1,147 @@
import Foundation
import Testing
@testable import TypedAppStorage
// MARK: - Test Models
struct TestUser: TypedAppStorageValue, Equatable {
static var appStorageKey = "testUser"
static var defaultValue = TestUser(name: "Anonymous", age: 0)
let name: String
let age: Int
}
struct TestSettings: TypedAppStorageValue, Equatable {
static var appStorageKey = "testSettings"
static var defaultValue = TestSettings(theme: .light, notifications: true)
enum Theme: String, Codable {
case light, dark
}
let theme: Theme
let notifications: Bool
}
// MARK: - Tests
@Suite("TypedUserDefaults Tests")
struct TypedUserDefaultsTests {
// Helper to create a clean test environment
private func createTestDefaults() -> TypedUserDefaults {
let userDefaults = UserDefaults()
userDefaults.removePersistentDomain(forName: Bundle.main.bundleIdentifier ?? "test")
return TypedUserDefaults(userDefaults: userDefaults)
}
@Test("Should return default value when no stored value exists")
func testDefaultValueRetrieval() {
let typedDefaults = createTestDefaults()
let user = typedDefaults.object(of: TestUser.self)
#expect(user == TestUser.defaultValue)
#expect(user.name == "Anonymous")
#expect(user.age == 0)
}
@Test("Should store and retrieve typed objects correctly")
func testStoreAndRetrieve() {
let typedDefaults = createTestDefaults()
let testUser = TestUser(name: "John Doe", age: 30)
// Store the value
typedDefaults.set(testUser)
// Retrieve the value
let retrievedUser = typedDefaults.object(of: TestUser.self)
#expect(retrievedUser == testUser)
#expect(retrievedUser.name == "John Doe")
#expect(retrievedUser.age == 30)
}
@Test("Should handle custom keys and default values")
func testCustomKeysAndDefaults() {
let typedDefaults = createTestDefaults()
let customUser = TestUser(name: "Jane Smith", age: 25)
let customDefault = TestUser(name: "Custom Default", age: 99)
let customKey = "customUserKey"
// Store with custom key
typedDefaults.set(customUser, forKey: customKey)
// Retrieve with custom key and custom default
let retrievedUser = typedDefaults.object(
of: TestUser.self, forKey: customKey, defaultValue: customDefault)
#expect(retrievedUser == customUser)
// Test retrieval from non-existent key with custom default
let nonExistentUser = typedDefaults.object(
of: TestUser.self, forKey: "nonExistentKey", defaultValue: customDefault)
#expect(nonExistentUser == customDefault)
#expect(nonExistentUser.name == "Custom Default")
#expect(nonExistentUser.age == 99)
}
@Test("Should remove stored objects correctly")
func testRemoveObject() {
let typedDefaults = createTestDefaults()
let testUser = TestUser(name: "To Be Removed", age: 40)
// Store the value
typedDefaults.set(testUser)
// Verify it's stored
let storedUser = typedDefaults.object(of: TestUser.self)
#expect(storedUser == testUser)
// Remove the value
typedDefaults.removeObject(of: TestUser.self)
// Verify it returns default value after removal
let removedUser = typedDefaults.object(of: TestUser.self)
#expect(removedUser == TestUser.defaultValue)
#expect(removedUser.name == "Anonymous")
#expect(removedUser.age == 0)
}
@Test("Should handle complex data types and synchronization")
func testComplexTypesAndSynchronization() {
let typedDefaults = createTestDefaults()
let testSettings = TestSettings(theme: .dark, notifications: false)
// Store complex type
typedDefaults.set(testSettings)
// Test synchronization
let syncResult = typedDefaults.synchronize()
#expect(syncResult == true)
// Retrieve and verify complex type
let retrievedSettings = typedDefaults.object(of: TestSettings.self)
#expect(retrievedSettings == testSettings)
#expect(retrievedSettings.theme == .dark)
#expect(retrievedSettings.notifications == false)
// Test with custom key for complex type
let customKey = "customSettings"
typedDefaults.set(testSettings, forKey: customKey)
let customRetrievedSettings = typedDefaults.object(of: TestSettings.self, forKey: customKey)
#expect(customRetrievedSettings == testSettings)
// Remove with custom key
typedDefaults.removeObject(of: TestSettings.self, forKey: customKey)
let removedCustomSettings = typedDefaults.object(of: TestSettings.self, forKey: customKey)
#expect(removedCustomSettings == TestSettings.defaultValue)
#expect(removedCustomSettings.theme == .light)
#expect(removedCustomSettings.notifications == true)
}
}