This commit is contained in:
Shibo Lyu 2023-09-01 00:04:39 +08:00
parent 68741ad06d
commit 2c5ce337e8
6 changed files with 254 additions and 27 deletions

4
.spi.yml Normal file
View file

@ -0,0 +1,4 @@
version: 1
builder:
configs:
- documentation_targets: [TypedAppStorage]

View file

@ -1,23 +1,30 @@
// swift-tools-version: 5.9
// swift-tools-version: 5.8
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "TypedAppStorage",
products: [
// Products define the executables and libraries a package produces, making them visible to other packages.
.library(
name: "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"),
.testTarget(
name: "TypedAppStorageTests",
dependencies: ["TypedAppStorage"]),
]
name: "TypedAppStorage",
platforms: [
.iOS(.v14),
.macOS(.v11),
.macCatalyst(.v14),
.tvOS(.v14),
.watchOS(.v7)
],
products: [
// Products define the executables and libraries a package produces, making them visible to other packages.
.library(
name: "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"),
.testTarget(
name: "TypedAppStorageTests",
dependencies: ["TypedAppStorage"]),
]
)

View file

@ -0,0 +1,15 @@
# ``TypedAppStorage``
A type-safe way to save and read complex data structures from `@AppStorage`.
- Use actual `@AppStorage` underneath
- Support any `Codable` data
- Define the key in the data model
## Topics
### Essentials
- <doc:GettingStarted>
- ``TypedAppStorage``
- ``TypedAppStorageValue``

View file

@ -0,0 +1,71 @@
# Getting Started
(Almost) as easy as `@AppStorage`.
## Overview
Add this package, define the data model with ``TypedAppStorageValue`` conformance, and then read and write with `@TypedAppStorage`.
### Add to Dependencies
Add this Swift Package using URL `https://github.com/laosb/TypedAppStorage`.
### Define Your Data Model
To use with ``TypedAppStorage/TypedAppStorage``, your data model must conforms to ``TypedAppStorageValue``.
``TypedAppStorageValue`` is essentially just `Codable` with ``TypedAppStorageValue/appStorageKey`` to define which `UserDefault` key the data should be saved under (the first parameter of `@AppStorage`), and ``TypedAppStorageValue/defaultValue`` to define an uniform default value for this specific type:
```swift
struct PreferredFruit: TypedAppStorageValue {
enum Fruit: Codable, CaseIterable {
case apple, pear, banana
}
enum Freshness: Codable, CaseIterable {
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
}
}
```
In most cases, you want a specific type to be stored under a specific key, with the same default value no matter where you use it.
By defining both alongside the data model, you take out the unnecessary duplication. So when used, you can omit both:
```swift
@TypedAppStorage var preferredFruit: PreferredFruit
```
Here, less duplication means smaller chance you may mess it up.
### Use in SwiftUI Views
As mentioned above, you can use it in SwiftUI views with even less ceremony:
```swift
struct PreferredFruitPicker: View {
@TypedAppStorage var preferredFruit: PreferredFruit
var body: some View {
Picker(selection: $preferredFruit.freshness) { /* ... */ }
Picker(selection: $preferredFruit.fruit) { /* ... */ }
}
}
```
In some cases it might make sense to specify a different default value than the one defined in ``TypedAppStorageValue``:
```swift
@TypedAppStorage var preferredFruit: PreferredFruit = .init(.somewhatStale, .banana)
```
And you can specify a different store just like `@AppStorage`, if you're using things like App Groups.

View file

@ -1,2 +1,62 @@
// The Swift Programming Language
// https://docs.swift.org/swift-book
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 {
/// 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``.
static var defaultValue: Self { get }
}
/// Store and fetch typed data from `@AppStorage`.
///
/// Define the data model with ``TypedAppStorageValue`` conformance, then read and write with `@TypedAppStorage`.
/// See <doc:GettingStarted> for more information.
@propertyWrapper
public struct TypedAppStorage<Value: TypedAppStorageValue>: DynamicProperty {
private var appStorage: AppStorage<String>
private var initialValue: Value
/// 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)
}
/// 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
}
nonmutating set {
guard
let newData = try? JSONEncoder().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 {
wrappedValue
} set: { newValue in
wrappedValue = newValue
}
}
}

View file

@ -1,12 +1,82 @@
import XCTest
import SwiftUI
@testable import TypedAppStorage
final class TypedAppStorageTests: XCTestCase {
func testExample() throws {
// XCTest Documentation
// https://developer.apple.com/documentation/xctest
// Defining Test Cases and Test Methods
// https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods
}
struct PreferredFruit: TypedAppStorageValue, Equatable {
enum Fruit: Codable {
case apple, pear, banana
}
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
}
}
struct TestArticle: View {
@TypedAppStorage var preferredFruit: PreferredFruit
func changePreferred(to newValue: PreferredFruit) {
preferredFruit = newValue
}
var body: some View {
Text("Test")
}
}
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() {
UserDefaults.standard.removeObject(forKey: "preferredFruit")
}
func testReadDefaultValue() throws {
let testArticle = TestArticle()
XCTAssertEqual(testArticle.preferredFruit, PreferredFruit(.veryFresh, .apple))
}
func testCallSiteDefault() throws {
let testArticle = TestArticleWithADifferentDefault()
XCTAssertEqual(testArticle.preferredFruit, .init(.moderate, .pear))
}
func testSaveAndReadBack() throws {
let testArticle = TestArticle()
testArticle.changePreferred(to: .init(.somewhatStale, .banana))
XCTAssertEqual(testArticle.preferredFruit, .init(.somewhatStale, .banana))
}
func testSaveAndReadElsewhere() throws {
let articleA = TestArticle()
let articleB = TestArticle()
articleA.changePreferred(to: .init(.moderate, .banana))
XCTAssertEqual(articleB.preferredFruit, .init(.moderate, .banana))
}
}