From 2c5ce337e8555a26e3ab5a6afac478f7b444dcf1 Mon Sep 17 00:00:00 2001 From: Shibo Lyu Date: Fri, 1 Sep 2023 00:04:39 +0800 Subject: [PATCH] Init. --- .spi.yml | 4 + Package.swift | 41 +++++---- .../Documentation.docc/Documentation.md | 15 ++++ .../Documentation.docc/GettingStarted.md | 71 +++++++++++++++ Sources/TypedAppStorage/TypedAppStorage.swift | 64 +++++++++++++- .../TypedAppStorageTests.swift | 86 +++++++++++++++++-- 6 files changed, 254 insertions(+), 27 deletions(-) create mode 100644 .spi.yml create mode 100644 Sources/TypedAppStorage/Documentation.docc/Documentation.md create mode 100644 Sources/TypedAppStorage/Documentation.docc/GettingStarted.md diff --git a/.spi.yml b/.spi.yml new file mode 100644 index 0000000..5a1b37d --- /dev/null +++ b/.spi.yml @@ -0,0 +1,4 @@ +version: 1 +builder: + configs: + - documentation_targets: [TypedAppStorage] diff --git a/Package.swift b/Package.swift index 68080a4..8a61557 100644 --- a/Package.swift +++ b/Package.swift @@ -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"]), + ] ) diff --git a/Sources/TypedAppStorage/Documentation.docc/Documentation.md b/Sources/TypedAppStorage/Documentation.docc/Documentation.md new file mode 100644 index 0000000..7bbd3da --- /dev/null +++ b/Sources/TypedAppStorage/Documentation.docc/Documentation.md @@ -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 + +- +- ``TypedAppStorage`` +- ``TypedAppStorageValue`` diff --git a/Sources/TypedAppStorage/Documentation.docc/GettingStarted.md b/Sources/TypedAppStorage/Documentation.docc/GettingStarted.md new file mode 100644 index 0000000..b64a451 --- /dev/null +++ b/Sources/TypedAppStorage/Documentation.docc/GettingStarted.md @@ -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. diff --git a/Sources/TypedAppStorage/TypedAppStorage.swift b/Sources/TypedAppStorage/TypedAppStorage.swift index 08b22b8..062c8a7 100644 --- a/Sources/TypedAppStorage/TypedAppStorage.swift +++ b/Sources/TypedAppStorage/TypedAppStorage.swift @@ -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 for more information. +@propertyWrapper +public struct TypedAppStorage: DynamicProperty { + private var appStorage: AppStorage + 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 { + .init { + wrappedValue + } set: { newValue in + wrappedValue = newValue + } + } +} diff --git a/Tests/TypedAppStorageTests/TypedAppStorageTests.swift b/Tests/TypedAppStorageTests/TypedAppStorageTests.swift index 9d87eeb..1b0b10d 100644 --- a/Tests/TypedAppStorageTests/TypedAppStorageTests.swift +++ b/Tests/TypedAppStorageTests/TypedAppStorageTests.swift @@ -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)) + } }