mirror of
https://github.com/laosb/TypedAppStorage.git
synced 2025-04-30 09:11:10 +00:00
Init.
This commit is contained in:
parent
68741ad06d
commit
2c5ce337e8
6 changed files with 254 additions and 27 deletions
4
.spi.yml
Normal file
4
.spi.yml
Normal file
|
@ -0,0 +1,4 @@
|
|||
version: 1
|
||||
builder:
|
||||
configs:
|
||||
- documentation_targets: [TypedAppStorage]
|
|
@ -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"]),
|
||||
]
|
||||
)
|
||||
|
|
15
Sources/TypedAppStorage/Documentation.docc/Documentation.md
Normal file
15
Sources/TypedAppStorage/Documentation.docc/Documentation.md
Normal 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``
|
71
Sources/TypedAppStorage/Documentation.docc/GettingStarted.md
Normal file
71
Sources/TypedAppStorage/Documentation.docc/GettingStarted.md
Normal 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.
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue