-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
6 changed files
with
253 additions
and
26 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
version: 1 | ||
builder: | ||
configs: | ||
- documentation_targets: [TypedAppStorage] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 changes: 15 additions & 0 deletions
15
Sources/TypedAppStorage/Documentation.docc/Documentation.md
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 changes: 71 additions & 0 deletions
71
Sources/TypedAppStorage/Documentation.docc/GettingStarted.md
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
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") | ||
} | ||
} | ||
|
||
// Defining Test Cases and Test Methods | ||
// https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods | ||
} | ||
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)) | ||
} | ||
} |