mirror of
https://github.com/laosb/TypedAppStorage.git
synced 2025-06-23 17:51:08 +00:00
Compare commits
6 commits
23ffb4892b
...
4fa2f61532
Author | SHA1 | Date | |
---|---|---|---|
![]() |
4fa2f61532 | ||
![]() |
b94a694193 | ||
![]() |
3649c706d9 | ||
![]() |
b3326032f6 | ||
![]() |
27d4026050 | ||
![]() |
4c932cb336 |
7 changed files with 355 additions and 47 deletions
|
@ -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"]
|
||||
),
|
||||
]
|
||||
)
|
||||
|
|
|
@ -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``
|
||||
|
|
|
@ -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)
|
||||
```
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
91
Sources/TypedAppStorage/TypedUserDefaults.swift
Normal file
91
Sources/TypedAppStorage/TypedUserDefaults.swift
Normal 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()
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
147
Tests/TypedAppStorageTests/TypedUserDefaultsTests.swift
Normal file
147
Tests/TypedAppStorageTests/TypedUserDefaultsTests.swift
Normal 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)
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue