diff --git a/Examples/Search/Search.xcodeproj/project.xcworkspace/xcuserdata/danil.xcuserdatad/UserInterfaceState.xcuserstate b/Examples/Search/Search.xcodeproj/project.xcworkspace/xcuserdata/danil.xcuserdatad/UserInterfaceState.xcuserstate index 1f0ca1a..fc9b4e7 100644 Binary files a/Examples/Search/Search.xcodeproj/project.xcworkspace/xcuserdata/danil.xcuserdatad/UserInterfaceState.xcuserstate and b/Examples/Search/Search.xcodeproj/project.xcworkspace/xcuserdata/danil.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/README.md b/README.md index a26a1d5..afe5179 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,9 @@ ## Introduction -VDStore is a minimalistic iOS architecture library designed to manage application state in a clean and native manner. It provides a `Store` struct that enables state mutation, state subscription, di injection, and fragmentation into scopes for scaling. VDStore is compatible with both SwiftUI and UIKit. +VDStore is a minimalistic iOS architecture library designed to manage application state in a clean and native manner. +It provides a `Store` struct that enables state mutation, state subscription, di injection, and fragmentation into scopes for scaling. +VDStore is compatible with both SwiftUI and UIKit. ## Features @@ -50,7 +52,8 @@ struct CounterView: View { } } ``` -`ViewStore` is a property wrapper that automatically subscribes to state changes and updates the view. `ViewStore` can be initialized with either `Store` or `State` instances. +`ViewStore` is a property wrapper that automatically subscribes to state changes and updates the view. +`ViewStore` can be initialized with either `Store` or `State` instances. ### Using with UIKit @@ -78,7 +81,10 @@ final class CounterViewController: UIViewController { ### Defining actions You can edit the state in any way you prefer, but the simplest one is extending Store. -There is a helper macro called `@Actions`. `@Actions` redirect all your methods calls through your custom middlewares that allows you to intrecept all calls in runtime. For example, you can use it to log all calls or state changes. Also `@Actions` make all your `async` methods cancellable. +There is a helper macro called `@Actions`. +`@Actions` redirect all your methods calls through your custom middlewares that allows you to intrecept all calls in runtime. +For example, you can use it to log all calls or state changes. +Also `@Actions` make all your `async` methods cancellable. ```swift @Actions extension Store { @@ -158,7 +164,7 @@ import PackageDescription let package = Package( name: "SomeProject", dependencies: [ - .package(url: "https://github.com/dankinsoid/VDStore.git", from: "0.18.0") + .package(url: "https://github.com/dankinsoid/VDStore.git", from: "0.19.0") ], targets: [ .target(name: "SomeProject", dependencies: ["VDStore"]) diff --git a/Sources/VDStore/Store.swift b/Sources/VDStore/Store.swift index 48e83ff..62c7feb 100644 --- a/Sources/VDStore/Store.swift +++ b/Sources/VDStore/Store.swift @@ -25,10 +25,11 @@ import Foundation /// /// ### Scoping /// -/// The most important operation defined on ``Store`` is the ``scope(get:set:)`` or ``scope(_ keyPayh:)`` method, +/// The most important operation defined on ``Store`` is the ``scope(get:set:)`` or ``scope(_ keyPayh:)`` methods, /// which allows you to transform a store into one that deals with child state. This is /// necessary for passing stores to subviews that only care about a small portion of the entire /// application's domain. +/// The store supports dynamic member lookup so that you can scope with a specific field in the state. /// /// For example, if an application has a tab view at its root with tabs for activity, search, and /// profile, then we can model the domain like this: @@ -61,17 +62,17 @@ import Foundation /// var body: some View { /// TabView { /// ActivityView( -/// $state.scope(\.activity) +/// $state.activity /// ) /// .tabItem { Text("Activity") } /// /// SearchView( -/// $state.scope(\.search) +/// $state.search /// ) /// .tabItem { Text("Search") } /// /// ProfileView( -/// $state.scope(\.profile) +/// $state.profile /// ) /// .tabItem { Text("Profile") } /// } @@ -84,17 +85,13 @@ import Foundation /// The `Store` class is isolated to main thread by @MainActor attribute. @MainActor @propertyWrapper +@dynamicMemberLookup public struct Store { /// The state of the store. public var state: State { get { box.state } - nonmutating set { - if suspendAllSyncStoreUpdates, !box.isUpdating { - suspendSyncUpdates() - } - box.state = newValue - } + nonmutating set { box.state = newValue } } /// Injected dependencies. @@ -104,8 +101,7 @@ public struct Store { /// A publisher that emits when state changes. /// - /// This publisher supports dynamic member lookup so that you can pluck out a specific field in - /// the state: + /// This publisher supports dynamic member lookup so that you can pluck out a specific field in the state: /// /// ```swift /// store.publisher.alert @@ -171,7 +167,11 @@ public struct Store { /// // Construct a login view by scoping the store /// // to one that works with only login domain. /// LoginView( - /// store.scope(state: \.login) + /// store.scope { + /// $0.login + /// } set: { + /// $0.login = $1 + /// } /// ) /// ``` /// @@ -217,7 +217,7 @@ public struct Store { /// `LoginView` could be extracted to a module that has no access to `AppFeature`. /// /// - Parameters: - /// - state: A writable key path from `State` to `ChildState`. + /// - keyPath: A writable key path from `State` to `ChildState`. /// - Returns: A new store with its state transformed. public func scope(_ keyPath: WritableKeyPath) -> Store { scope { @@ -227,6 +227,39 @@ public struct Store { } } + /// Scopes the store to one that exposes child state. + /// + /// This can be useful for deriving new stores to hand to child views in an application. For + /// example: + /// + /// ```swift + /// struct AppFeature { + /// var login: Login.State + /// // ... + /// } + /// + /// // A store that runs the entire application. + /// let store = Store(AppFeature()) + /// + /// // Construct a login view by scoping the store + /// // to one that works with only login domain. + /// LoginView( + /// store.login + /// ) + /// ``` + /// + /// Scoping in this fashion allows you to better modularize your application. In this case, + /// `LoginView` could be extracted to a module that has no access to `AppFeature`. + /// + /// - Parameters: + /// - keyPath: A writable key path from `State` to `ChildState`. + /// - Returns: A new store with its state transformed. + public subscript( + dynamicMember keyPath: WritableKeyPath + ) -> Store { + scope(keyPath) + } + /// Injects the given value into the store's. /// - Parameters: /// - keyPath: A key path to the value in the store's dependencies. @@ -265,19 +298,24 @@ public struct Store { /// Suspends the store from updating the UI until the block returns. public func update(_ update: () throws -> T) rethrows -> T { - if !suspendAllSyncStoreUpdates, !box.isUpdating { - defer { box.afterUpdate() } - box.beforeUpdate() - } + defer { box.afterUpdate() } + box.beforeUpdate() let result = try update() return result } +} - /// Suspends the store from updating the UI while all synchronous operations are being performed. - public func suspendSyncUpdates() { - box.beforeUpdate() - DispatchQueue.main.async { [box] in - box.afterUpdate() +public extension Store where State: MutableCollection { + + subscript(_ index: State.Index) -> Store { + scope(index) + } + + func scope(_ index: State.Index) -> Store { + scope { + $0[index] + } set: { + $0[index] = $1 } } } diff --git a/Sources/VDStore/StoreExtensions/Iflet.swift b/Sources/VDStore/StoreExtensions/Iflet.swift new file mode 100644 index 0000000..f5fad10 --- /dev/null +++ b/Sources/VDStore/StoreExtensions/Iflet.swift @@ -0,0 +1,48 @@ +import Foundation + +public extension Store { + + func or(_ defaultValue: @escaping @autoclosure () -> T) -> Store where T? == State { + scope { + $0 ?? defaultValue() + } set: { + $0 = $1 + } + } + + func onChange( + of keyPath: WritableKeyPath, + removeDuplicates isDuplicate: @escaping (V, V) -> Bool, + _ operation: @MainActor @escaping (_ oldValue: V, _ newValue: V, inout State) -> Void + ) -> Store { + scope { + $0 + } set: { + let oldValue = $0[keyPath: keyPath] + $0 = $1 + operation(oldValue, $1[keyPath: keyPath], &$0) + } + } + + func onChange( + of keyPath: WritableKeyPath, + _ operation: @MainActor @escaping (_ oldValue: V, _ newValue: V, inout State) -> Void + ) -> Store { + onChange(of: keyPath, removeDuplicates: ==, operation) + } +} + +public extension Store where State: MutableCollection { + + func forEach(_ operation: @MainActor (Store) throws -> Void) rethrows { + for index in state.indices { + try operation(self[index]) + } + } + + func forEach(_ operation: @MainActor (Store) async throws -> Void) async rethrows { + for index in state.indices { + try await operation(self[index]) + } + } +} diff --git a/Sources/VDStore/Utils/StoreBox.swift b/Sources/VDStore/Utils/StoreBox.swift index f693a1c..0a92ba4 100644 --- a/Sources/VDStore/Utils/StoreBox.swift +++ b/Sources/VDStore/Utils/StoreBox.swift @@ -1,4 +1,5 @@ import Combine +import Foundation struct StoreBox: Publisher { @@ -6,37 +7,24 @@ struct StoreBox: Publisher { var state: Output { get { getter() } - nonmutating set { setter(newValue, true) } + nonmutating set { setter(newValue) } } - var isUpdating: Bool { updatesCounter.wrappedValue > 0 } - var willSet: AnyPublisher { publisher(_willSet) } + let willSet: AnyPublisher + let beforeUpdate: () -> Void + let afterUpdate: () -> Void private let getter: () -> Output - private let setter: (Output, _ sendWillSet: Bool) -> Void - private let _willSet: PassthroughSubject - private let updatesCounter: Ref + private let setter: (Output) -> Void private let valuePublisher: AnyPublisher init(_ value: Output) { - let willSet = PassthroughSubject() - _willSet = willSet - - let valuePublisher = CurrentValueSubject(value) - getter = { valuePublisher.value } - setter = { value, sendWillSet in - if sendWillSet { - willSet.send() - } - valuePublisher.send(value) - } - self.valuePublisher = valuePublisher.eraseToAnyPublisher() - - var updatesCounter: UInt = 0 - self.updatesCounter = Ref { - updatesCounter - } set: { - updatesCounter = $0 - } + let rootBox = StoreRootBox(value) + willSet = rootBox.willSetPublisher + valuePublisher = rootBox.eraseToAnyPublisher() + getter = { rootBox.state } + setter = { rootBox.state = $0 } + beforeUpdate = rootBox.beforeUpdate + afterUpdate = rootBox.afterUpdate } init( @@ -45,38 +33,78 @@ struct StoreBox: Publisher { set: @escaping (inout T, Output) -> Void ) { valuePublisher = parent.valuePublisher.map(get).eraseToAnyPublisher() - _willSet = parent._willSet - updatesCounter = parent.updatesCounter + willSet = parent.willSet getter = { get(parent.getter()) } setter = { var state = parent.getter() set(&state, $0) - parent.setter(state, $1) + parent.setter(state) } + beforeUpdate = parent.beforeUpdate + afterUpdate = parent.afterUpdate } - func beforeUpdate() { - if updatesCounter.wrappedValue == 0 { - _willSet.send() - } - updatesCounter.wrappedValue &+= 1 + func receive(subscriber: S) where S: Subscriber, Never == S.Failure, Output == S.Input { + valuePublisher.receive(subscriber: subscriber) } +} - func afterUpdate() { - updatesCounter.wrappedValue &-= 1 - if updatesCounter.wrappedValue == 0 { - setter(getter(), false) +private final class StoreRootBox: Publisher { + + typealias Output = State + typealias Failure = Never + + var state: State { + get { subject.value } + set { + if suspendAllSyncStoreUpdates, updatesCounter == 0 { + suspendSyncUpdates() + } else if updatesCounter == 0 { + willSet.send() + } + subject.value = newValue } } + var willSetPublisher: AnyPublisher { publisher(willSet) } + + private var updatesCounter = 0 + private let willSet = PassthroughSubject() + private let subject: CurrentValueSubject + + init(_ state: State) { + subject = CurrentValueSubject(state) + } + func receive(subscriber: S) where S: Subscriber, Never == S.Failure, Output == S.Input { - publisher(valuePublisher).receive(subscriber: subscriber) + publisher(subject).receive(subscriber: subscriber) } private func publisher(_ publisher: P) -> AnyPublisher { - publisher.filter { [updatesCounter] _ in - updatesCounter.wrappedValue == 0 + publisher.filter { [weak self] _ in + self?.updatesCounter == 0 } .eraseToAnyPublisher() } + + private func suspendSyncUpdates() { + beforeUpdate() + DispatchQueue.main.async { [self] in + afterUpdate() + } + } + + func beforeUpdate() { + if updatesCounter == 0 { + willSet.send() + } + updatesCounter &+= 1 + } + + func afterUpdate() { + updatesCounter &-= 1 + if updatesCounter == 0 { + subject.value = state + } + } } diff --git a/Tests/VDStoreTests/VDStoreTests.swift b/Tests/VDStoreTests/VDStoreTests.swift index 747bc0d..c4988a0 100644 --- a/Tests/VDStoreTests/VDStoreTests.swift +++ b/Tests/VDStoreTests/VDStoreTests.swift @@ -32,7 +32,7 @@ final class VDStoreTests: XCTestCase { func testScopedStoreInheritsDependencies() { let service: SomeService = MockSomeService() let parentStore = Store(Counter()).di(\.someService, service) - let childStore = parentStore.scope(\.counter) + let childStore = parentStore.counter XCTAssert(childStore.di.someService === service) } @@ -71,6 +71,24 @@ final class VDStoreTests: XCTestCase { XCTAssertEqual(value, 6) } + func testUpdate() { + let store = Store(Counter()) + let publisher = store.publisher + var count = 0 + let cancellable = publisher + .sink { _ in + count += 1 + } + cancellable.store(in: &store.di.cancellableSet) + store.update { + for _ in 0 ..< 10 { + store.add() + } + } + XCTAssertEqual(store.state.counter, 10) + XCTAssertEqual(count, 2) + } + #if swift(>=5.9) /// Test that the publisher property of a Store sends updates when the state changes. func testPublisherUpdates() async { @@ -115,6 +133,19 @@ final class VDStoreTests: XCTestCase { await fulfillment(of: [expectation], timeout: 0.1) XCTAssertEqual(count, 2) } + + func testOnChange() async { + let expectation = expectation(description: "Counter") + let store = Store(Counter()).onChange(of: \.counter) { _, _, state in + state.counter += 1 + } + store.add() + DispatchQueue.main.async { + expectation.fulfill() + } + await fulfillment(of: [expectation], timeout: 0.1) + XCTAssertEqual(store.state.counter, 2) + } #endif }