Skip to content

Commit

Permalink
Add StoredState backed by UserDefaults (#18)
Browse files Browse the repository at this point in the history
  • Loading branch information
0xLeif authored Nov 5, 2023
1 parent f88a023 commit a290b0a
Show file tree
Hide file tree
Showing 5 changed files with 241 additions and 12 deletions.
11 changes: 2 additions & 9 deletions Sources/AppState/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down
76 changes: 76 additions & 0 deletions Sources/AppState/Application+StoredState.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import Foundation

extension Application {
/// The shared `UserDefaults` instance.
public var userDefaults: Dependency<UserDefaults> {
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<Value>: 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<Value>.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)
}
}
}
52 changes: 49 additions & 3 deletions Sources/AppState/Application+public.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Value>(_ keyPath: KeyPath<Application, Dependency<Value>>) -> Value {
static func dependency<Value>(
_ keyPath: KeyPath<Application, Dependency<Value>>
) -> Value {
shared.value(keyPath: keyPath).value
}

Expand All @@ -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<Value>(
storedState keyPath: KeyPath<Application, StoredState<Value>>
) {
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<Value>(_ keyPath: KeyPath<Application, State<Value>>) -> State<Value> {
static func state<Value>(
_ keyPath: KeyPath<Application, State<Value>>
) -> State<Value> {
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<Value>(
_ keyPath: KeyPath<Application, StoredState<Value>>
) -> StoredState<Value> {
shared.value(keyPath: keyPath)
}

Expand Down Expand Up @@ -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<Value>(
initial: @escaping @autoclosure () -> Value,
feature: String = "App",
id: String
) -> StoredState<Value> {
StoredState(
initial: initial(),
scope: Scope(name: feature, id: id)
)
}
}
61 changes: 61 additions & 0 deletions Sources/AppState/StoredState.swift
Original file line number Diff line number Diff line change
@@ -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<Value>: DynamicProperty {
/// Holds the singleton instance of `Application`.
@ObservedObject private var app: Application = Application.shared

/// Path for accessing `StoredState` from Application.
private let keyPath: KeyPath<Application, Application.StoredState<Value>>

/// 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<Value> {
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<Application, Application.StoredState<Value>>
) {
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<OuterSelf: ObservableObject>(
_enclosingInstance observed: OuterSelf,
wrapped wrappedKeyPath: ReferenceWritableKeyPath<OuterSelf, Value>,
storage storageKeyPath: ReferenceWritableKeyPath<OuterSelf, Self>
) -> Value {
get {
observed[keyPath: storageKeyPath].wrappedValue
}
set {
guard
let publisher = observed.objectWillChange as? ObservableObjectPublisher
else { return }

publisher.send()
observed[keyPath: storageKeyPath].wrappedValue = newValue
}
}
}
53 changes: 53 additions & 0 deletions Tests/AppStateTests/AppStateTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Int> {
storedState(initial: -1, id: "storedValue")
}
}

class ExampleViewModel: ObservableObject {
Expand All @@ -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
Expand Down Expand Up @@ -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)
}
}

0 comments on commit a290b0a

Please sign in to comment.