Skip to content

Commit

Permalink
Init.
Browse files Browse the repository at this point in the history
  • Loading branch information
laosb committed Aug 31, 2023
1 parent 68741ad commit 2c5ce33
Show file tree
Hide file tree
Showing 6 changed files with 253 additions and 26 deletions.
4 changes: 4 additions & 0 deletions .spi.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
version: 1
builder:
configs:
- documentation_targets: [TypedAppStorage]
41 changes: 24 additions & 17 deletions Package.swift
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 Sources/TypedAppStorage/Documentation.docc/Documentation.md
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 Sources/TypedAppStorage/Documentation.docc/GettingStarted.md
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.
64 changes: 62 additions & 2 deletions Sources/TypedAppStorage/TypedAppStorage.swift
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
}
}
}
84 changes: 77 additions & 7 deletions Tests/TypedAppStorageTests/TypedAppStorageTests.swift
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))
}
}

0 comments on commit 2c5ce33

Please sign in to comment.