From a290b0aa811df62a52700a013043a4effa5c140b Mon Sep 17 00:00:00 2001 From: Zach Date: Sat, 4 Nov 2023 19:58:38 -0600 Subject: [PATCH] Add StoredState backed by UserDefaults (#18) --- Sources/AppState/AppState.swift | 11 +-- .../AppState/Application+StoredState.swift | 76 +++++++++++++++++++ Sources/AppState/Application+public.swift | 52 ++++++++++++- Sources/AppState/StoredState.swift | 61 +++++++++++++++ Tests/AppStateTests/AppStateTests.swift | 53 +++++++++++++ 5 files changed, 241 insertions(+), 12 deletions(-) create mode 100644 Sources/AppState/Application+StoredState.swift create mode 100644 Sources/AppState/StoredState.swift diff --git a/Sources/AppState/AppState.swift b/Sources/AppState/AppState.swift index 0f48114..12f7d31 100644 --- a/Sources/AppState/AppState.swift +++ b/Sources/AppState/AppState.swift @@ -15,15 +15,8 @@ import SwiftUI app.value(keyPath: keyPath).value } nonmutating set { - let scope = app.value(keyPath: keyPath).scope - - app.cache.set( - value: Application.State( - initial: newValue, - scope: scope - ), - forKey: scope.key - ) + var state = app.value(keyPath: keyPath) + state.value = newValue } } diff --git a/Sources/AppState/Application+StoredState.swift b/Sources/AppState/Application+StoredState.swift new file mode 100644 index 0000000..e87aec6 --- /dev/null +++ b/Sources/AppState/Application+StoredState.swift @@ -0,0 +1,76 @@ +import Foundation + +extension Application { + /// The shared `UserDefaults` instance. + public var userDefaults: Dependency { + dependency(UserDefaults.standard) + } + + /// `StoredState` encapsulates the value within the application's scope and allows any changes to be propagated throughout the scoped area. State is stored using `UserDefaults`. + public struct StoredState: CustomStringConvertible { + /// A private backing storage for the value. + private var initial: () -> Value + + /// The current state value. + public var value: Value { + get { + let userDefaults = Application.dependency(\.userDefaults) + let cachedValue = shared.cache.get( + scope.key, + as: State.self + ) + + if let cachedValue = cachedValue { + return cachedValue.value + } + + guard + let object = userDefaults.object(forKey: scope.key), + let storedValue = object as? Value + else { return initial() } + + return storedValue + } + set { + let userDefaults = Application.dependency(\.userDefaults) + + shared.cache.set( + value: Application.State( + initial: newValue, + scope: scope + ), + forKey: scope.key + ) + userDefaults.set(newValue, forKey: scope.key) + } + } + + /// The scope in which this state exists. + let scope: Scope + + /** + Creates a new state within a given scope initialized with the provided value. + + - Parameters: + - value: The initial value of the state + - scope: The scope in which the state exists + */ + init( + initial: @escaping @autoclosure () -> Value, + scope: Scope + ) { + self.initial = initial + self.scope = scope + } + + public var description: String { + "StoredState<\(Value.self)>(\(value)) (\(scope.key))" + } + + /// Removes the value from `UserDefaults`. + public mutating func remove() { + value = initial() + Application.dependency(\.userDefaults).removeObject(forKey: scope.key) + } + } +} diff --git a/Sources/AppState/Application+public.swift b/Sources/AppState/Application+public.swift index aeeb224..f7d4256 100644 --- a/Sources/AppState/Application+public.swift +++ b/Sources/AppState/Application+public.swift @@ -21,7 +21,9 @@ public extension Application { - Parameter keyPath: KeyPath of the state value to be fetched - Returns: The requested state of type `Value`. */ - static func dependency(_ keyPath: KeyPath>) -> Value { + static func dependency( + _ keyPath: KeyPath> + ) -> Value { shared.value(keyPath: keyPath).value } @@ -47,17 +49,41 @@ public extension Application { ) return DependencyOverride { - shared.cache.set(value: dependency, forKey: dependency.scope.key) + shared.cache.set( + value: dependency, + forKey: dependency.scope.key + ) } } + static func remove( + storedState keyPath: KeyPath> + ) { + var storedState = shared.value(keyPath: keyPath) + storedState.remove() + } + /** Retrieves a state from Application instance using the provided keypath. - Parameter keyPath: KeyPath of the state value to be fetched - Returns: The requested state of type `Value`. */ - static func state(_ keyPath: KeyPath>) -> State { + static func state( + _ keyPath: KeyPath> + ) -> State { + shared.value(keyPath: keyPath) + } + + /** + Retrieves a stored state from Application instance using the provided keypath. + + - Parameter keyPath: KeyPath of the state value to be fetched + - Returns: The requested state of type `Value`. + */ + static func storedState( + _ keyPath: KeyPath> + ) -> StoredState { shared.value(keyPath: keyPath) } @@ -156,4 +182,24 @@ public extension Application { ) ) } + + /** + Retrieves a `UserDefaults` backed state for the provided `id`. If the state is not present, it initializes a new state with the `initial` value. + + - Parameters: + - initial: The closure that returns initial state value. + - feature: The name of the feature to which the state belongs, default is "App". + - id: The specific identifier for this state. + - Returns: The state of type `Value`. + */ + func storedState( + initial: @escaping @autoclosure () -> Value, + feature: String = "App", + id: String + ) -> StoredState { + StoredState( + initial: initial(), + scope: Scope(name: feature, id: id) + ) + } } diff --git a/Sources/AppState/StoredState.swift b/Sources/AppState/StoredState.swift new file mode 100644 index 0000000..a31bcb6 --- /dev/null +++ b/Sources/AppState/StoredState.swift @@ -0,0 +1,61 @@ +import Foundation +import Combine +import SwiftUI + +/// `StoredState` is a property wrapper allowing SwiftUI views to subscribe to Application's state changes in a reactive way. State is stored using `UserDefaults`. Works similar to `State` and `Published`. +@propertyWrapper public struct StoredState: DynamicProperty { + /// Holds the singleton instance of `Application`. + @ObservedObject private var app: Application = Application.shared + + /// Path for accessing `StoredState` from Application. + private let keyPath: KeyPath> + + /// Represents the current value of the `StoredState`. + public var wrappedValue: Value { + get { + app.value(keyPath: keyPath).value + } + nonmutating set { + var state = app.value(keyPath: keyPath) + state.value = newValue + } + } + + /// A binding to the `State`'s value, which can be used with SwiftUI views. + public var projectedValue: Binding { + Binding( + get: { wrappedValue }, + set: { wrappedValue = $0 } + ) + } + + /** + Initializes the AppState with a `keyPath` for accessing `StoredState` in Application. + + - Parameter keyPath: The `KeyPath` for accessing `StoredState` in Application. + */ + public init( + _ keyPath: KeyPath> + ) { + self.keyPath = keyPath + } + + /// A property wrapper's synthetic storage property. This is just for SwiftUI to mutate the `wrappedValue` and send event through `objectWillChange` publisher when the `wrappedValue` changes + public static subscript( + _enclosingInstance observed: OuterSelf, + wrapped wrappedKeyPath: ReferenceWritableKeyPath, + storage storageKeyPath: ReferenceWritableKeyPath + ) -> Value { + get { + observed[keyPath: storageKeyPath].wrappedValue + } + set { + guard + let publisher = observed.objectWillChange as? ObservableObjectPublisher + else { return } + + publisher.send() + observed[keyPath: storageKeyPath].wrappedValue = newValue + } + } +} diff --git a/Tests/AppStateTests/AppStateTests.swift b/Tests/AppStateTests/AppStateTests.swift index e935a8a..5929cca 100644 --- a/Tests/AppStateTests/AppStateTests.swift +++ b/Tests/AppStateTests/AppStateTests.swift @@ -34,6 +34,10 @@ extension Application { var colors: State<[String: CGColor]> { state(initial: ["primary": CGColor(red: 1, green: 0, blue: 1, alpha: 1)]) } + + var storedValue: StoredState { + storedState(initial: -1, id: "storedValue") + } } class ExampleViewModel: ObservableObject { @@ -44,6 +48,23 @@ class ExampleViewModel: ObservableObject { } } +class ExampleStoringViewModel: ObservableObject { + @StoredState(\.storedValue) var count + + func testPropertyWrapper() { + count = 27 + _ = TextField( + value: $count, + format: .number, + label: { Text("Count") } + ) + } +} + +struct ExampleStoredValue { + @StoredState(\.storedValue) var count +} + struct ExampleView: View { @AppDependency(\.networking) var networking @AppState(\.username) var username @@ -118,4 +139,36 @@ final class AppStateTests: XCTestCase { XCTAssertEqual(viewModel.username, "Hello, ViewModel") } + + func testStoredState() { + XCTAssertEqual(Application.storedState(\.storedValue).value, -1) + + let storedValue = ExampleStoredValue() + + XCTAssertEqual(storedValue.count, -1) + + storedValue.count = 2 + + XCTAssertEqual(storedValue.count, 2) + + Application.remove(storedState: \.storedValue) + + XCTAssertEqual(Application.storedState(\.storedValue).value, -1) + } + + func testStoringViewModel() { + XCTAssertEqual(Application.storedState(\.storedValue).value, -1) + + let viewModel = ExampleStoringViewModel() + + XCTAssertEqual(viewModel.count, -1) + + viewModel.count = 2 + + XCTAssertEqual(viewModel.count, 2) + + Application.remove(storedState: \.storedValue) + + XCTAssertEqual(viewModel.count, -1) + } }