From ca8e43effaaff225032b3a3eaeac1689d2dfc2ad Mon Sep 17 00:00:00 2001 From: Ken Ackerson Date: Tue, 11 Apr 2023 13:20:01 -0400 Subject: [PATCH 01/18] Initial set of name changes to ViewStore -> Store. --- Example/Photos/PhotoList.swift | 14 +-- Example/Photos/PhotoListViewStore.swift | 36 ++++---- Sources/ViewStore/MockStore.swift | 24 +++++ Sources/ViewStore/MockViewStore.swift | 19 ---- Sources/ViewStore/ScopedStore.swift | 38 ++++++++ .../ViewStore/Store+BindingAdditions.swift | 90 +++++++++++++++++++ Sources/ViewStore/Store+Helpers.swift | 19 ++++ Sources/ViewStore/Store.swift | 34 +++++++ .../ViewStore+BindingAdditions.swift | 90 ------------------- Sources/ViewStore/ViewStore.swift | 30 ------- ViewStore.xcodeproj/project.pbxproj | 32 ++++--- 11 files changed, 252 insertions(+), 174 deletions(-) create mode 100644 Sources/ViewStore/MockStore.swift delete mode 100644 Sources/ViewStore/MockViewStore.swift create mode 100644 Sources/ViewStore/ScopedStore.swift create mode 100644 Sources/ViewStore/Store+BindingAdditions.swift create mode 100644 Sources/ViewStore/Store+Helpers.swift create mode 100644 Sources/ViewStore/Store.swift delete mode 100644 Sources/ViewStore/ViewStore+BindingAdditions.swift delete mode 100644 Sources/ViewStore/ViewStore.swift diff --git a/Example/Photos/PhotoList.swift b/Example/Photos/PhotoList.swift index 833a674..d79f715 100644 --- a/Example/Photos/PhotoList.swift +++ b/Example/Photos/PhotoList.swift @@ -8,14 +8,14 @@ import SwiftUI import Provider -/// Displays a list of photos retrieved from an API. Uses a `ViewStore` for coordination with the data source. +/// Displays a list of photos retrieved from an API. Uses a `Store` for coordination with the data source. struct PhotoList: View { @StateObject private var store: Store /// Creates a new `PhotoList`. /// - Parameters: - /// - store: The `ViewStore` that drives + /// - store: The `Store` that drives init(store: @autoclosure @escaping () -> Store) { self._store = StateObject(wrappedValue: store()) } @@ -25,7 +25,7 @@ struct PhotoList: View { var body: some View { NavigationView { ZStack { - switch store.viewState.status { + switch store.state.status { case .loading: ProgressView() .progressViewStyle(.circular) @@ -48,7 +48,7 @@ struct PhotoList: View { } } header: { Toggle("Show Count", isOn: store.showsPhotoCount) - .animation(.easeInOut, value: store.viewState.showsPhotoCount) + .animation(.easeInOut, value: store.state.showsPhotoCount) } } case let .error(error): @@ -60,7 +60,7 @@ struct PhotoList: View { } .navigationBarTitleDisplayMode(.inline) - .navigationTitle(store.viewState.navigationTitle) + .navigationTitle(store.state.navigationTitle) .searchable(text: store.searchText, placement: .navigationBarDrawer(displayMode: .always)) } } @@ -68,8 +68,8 @@ struct PhotoList: View { struct PhotoList_Previews: PreviewProvider { static var previews: some View { - let viewState = PhotoListViewStore.ViewState(status: .content(MockItemProvider(photosCount: 3).photos)) + let state = PhotoListViewStore.State(status: .content(MockItemProvider(photosCount: 3).photos)) - PhotoList(store: MockViewStore(viewState: viewState)) + PhotoList(store: MockStore(state: state)) } } diff --git a/Example/Photos/PhotoListViewStore.swift b/Example/Photos/PhotoListViewStore.swift index 5dd2a82..6cf3fb6 100644 --- a/Example/Photos/PhotoListViewStore.swift +++ b/Example/Photos/PhotoListViewStore.swift @@ -11,14 +11,14 @@ import Combine import SwiftUI import CasePaths -typealias PhotoListViewStoreType = ViewStore +typealias PhotoListViewStoreType = Store /// Coordinates state for use in `PhotoListView` -final class PhotoListViewStore: ViewStore { +final class PhotoListViewStore: Store { - // MARK: - ViewStore + // MARK: - Store - struct ViewState { + struct State { enum Status { case error(Error) case loading @@ -26,14 +26,14 @@ final class PhotoListViewStore: ViewStore { } fileprivate static let defaultNavigationTitle = LocalizedStringKey("Photos") - fileprivate static let initial = ViewState() + fileprivate static let initial = State() let status: Status let showsPhotoCount: Bool let navigationTitle: LocalizedStringKey let searchText: String - init(status: PhotoListViewStore.ViewState.Status = .loading, showsPhotoCount: Bool = false, navigationTitle: LocalizedStringKey = ViewState.defaultNavigationTitle, searchText: String = "") { + init(status: PhotoListViewStore.State.Status = .loading, showsPhotoCount: Bool = false, navigationTitle: LocalizedStringKey = State.defaultNavigationTitle, searchText: String = "") { self.status = status self.showsPhotoCount = showsPhotoCount self.navigationTitle = navigationTitle @@ -46,7 +46,11 @@ final class PhotoListViewStore: ViewStore { case search(String) } - @Published private(set) var viewState: ViewState = .initial + @Published private(set) var state: State = .initial + + var publishedState: AnyPublisher { + return $state.eraseToAnyPublisher() + } // MARK: - PhotoListViewStore @@ -60,9 +64,9 @@ final class PhotoListViewStore: ViewStore { /// - scheduler: Determines how state updates are scheduled to be delivered in the view store. Defaults to `default`, which asynchronously schedules updates on the main queue. init(provider: Provider, scheduler: MainQueueScheduler = .init(type: .default)) { self.provider = provider - let showsPhotosCountPublisher = self.showsPhotosCountPublisher.prepend(ViewState.initial.showsPhotoCount) + let showsPhotosCountPublisher = self.showsPhotosCountPublisher.prepend(State.initial.showsPhotoCount) let photoPublisher = provider.providePhotos().prepend([]) - let searchTextUIPublisher = self.searchTextPublisher.prepend(ViewState.initial.searchText) + let searchTextUIPublisher = self.searchTextPublisher.prepend(State.initial.searchText) let searchTextPublisher = searchTextUIPublisher.throttle(for: 1, scheduler: scheduler, latest: true) photoPublisher @@ -71,14 +75,14 @@ final class PhotoListViewStore: ViewStore { switch result { case let .success(photos): let filteredPhotos = photos.filter(searchText: searchText) - let navigationTitle = showsPhotosCount ? LocalizedStringKey("Photos \(filteredPhotos.count)") : ViewState.defaultNavigationTitle - return ViewState(status: .content(filteredPhotos), showsPhotoCount: showsPhotosCount, navigationTitle: navigationTitle, searchText: searchTextUI) + let navigationTitle = showsPhotosCount ? LocalizedStringKey("Photos \(filteredPhotos.count)") : State.defaultNavigationTitle + return State(status: .content(filteredPhotos), showsPhotoCount: showsPhotosCount, navigationTitle: navigationTitle, searchText: searchTextUI) case let .failure(error): - return ViewState(status: .error(error), showsPhotoCount: false, navigationTitle: ViewState.defaultNavigationTitle, searchText: searchTextUI) + return State(status: .error(error), showsPhotoCount: false, navigationTitle: State.defaultNavigationTitle, searchText: searchTextUI) } } .receive(on: scheduler) - .assign(to: &$viewState) + .assign(to: &$state) } // MARK: - ViewStore @@ -97,17 +101,17 @@ extension PhotoListViewStoreType { var showsPhotoCount: Binding { // // return Binding { -// self.viewState.showsPhotoCount +// self.state.showsPhotoCount // } set: { newValue in // self.send(.toggleShowsPhotoCount(newValue)) // } // // Note: This 👇 is just a shorthand version of this 👆 - makeBinding(viewStateKeyPath: \.showsPhotoCount, actionCasePath: /Action.toggleShowsPhotoCount) + makeBinding(stateKeyPath: \.showsPhotoCount, actionCasePath: /Action.toggleShowsPhotoCount) } var searchText: Binding { - makeBinding(viewStateKeyPath: \.searchText, actionCasePath: /Action.search) + makeBinding(stateKeyPath: \.searchText, actionCasePath: /Action.search) } } diff --git a/Sources/ViewStore/MockStore.swift b/Sources/ViewStore/MockStore.swift new file mode 100644 index 0000000..db4c61c --- /dev/null +++ b/Sources/ViewStore/MockStore.swift @@ -0,0 +1,24 @@ +// +// MockStore.swift +// ViewStore +// +// Created by Kenneth Ackerson on 12/22/22. +// + +import Foundation +import Combine + +/// A generic object conforming to `Store` that simply returns the passed-in state. Useful in SwiftUI previews. +public final class MockStore: Store { + public var publishedState: AnyPublisher { + return Just(state).eraseToAnyPublisher() + } + + public var state: State + + public init(state: State) { + self.state = state + } + + public func send(_ action: Action) {} +} diff --git a/Sources/ViewStore/MockViewStore.swift b/Sources/ViewStore/MockViewStore.swift deleted file mode 100644 index 9fda923..0000000 --- a/Sources/ViewStore/MockViewStore.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// MockViewStore.swift -// ViewStore -// -// Created by Kenneth Ackerson on 12/22/22. -// - -import Foundation - -/// A generic object conforming to `ViewStore` that simply returns the passed-in view state. Useful in SwiftUI previews. - public final class MockViewStore: ViewStore { - public var viewState: ViewState - - public init(viewState: ViewState) { - self.viewState = viewState - } - - public func send(_ action: Action) {} - } diff --git a/Sources/ViewStore/ScopedStore.swift b/Sources/ViewStore/ScopedStore.swift new file mode 100644 index 0000000..03386af --- /dev/null +++ b/Sources/ViewStore/ScopedStore.swift @@ -0,0 +1,38 @@ +// +// ScopedStore.swift +// ViewStore +// +// Created by Kenneth Ackerson on 4/3/23. +// + +import Foundation +import Combine +import CasePaths + +public final class ScopedStore: Store { + @Published public var state: State + + public var publishedState: AnyPublisher { + return $state.eraseToAnyPublisher() + } + + private let action: (Action) -> Void + + public init(initial: State, viewStatePub: some Publisher, action: @escaping (Action) -> Void) { + state = initial + self.action = action + viewStatePub.assign(to: &$state) + } + + public func send(_ action: Action) { + self.action(action) + } + +} + +public extension Store { + func scoped(stateKeyPath: KeyPath, actionCasePath: CasePath) -> any Store { + return ScopedStore(initial: state[keyPath: stateKeyPath], viewStatePub: publishedState.map(stateKeyPath), action: { self.send(actionCasePath.embed($0)) }) + } +} + diff --git a/Sources/ViewStore/Store+BindingAdditions.swift b/Sources/ViewStore/Store+BindingAdditions.swift new file mode 100644 index 0000000..da05e15 --- /dev/null +++ b/Sources/ViewStore/Store+BindingAdditions.swift @@ -0,0 +1,90 @@ +// +// Store+BindingAdditions.swift +// ViewStore +// +// Created by Twig on 7/21/22. +// + +import SwiftUI +import CasePaths +import Combine + +/// An extension on `Store` that provides conveniences for creating `Binding`s. +public extension Store { + + /// Provides a mechanism for creating `Binding`s associated with `Action` cases in a `Store`, leveraging `KeyPath`s to reduce duplication and errors. Supports `enum` cases with associated values. + /// - Parameters: + /// - stateKeyPath: The `KeyPath` to the `State` property that this binding wraps. + /// - actionCasePath: The `CasePath` to the `Action` case associated with the `State` property. + func makeBinding(stateKeyPath: KeyPath, actionCasePath: CasePath) -> Binding { + return .init { + self.state[keyPath: stateKeyPath] + } set: { value in + self.send(actionCasePath.embed(value)) + } + } + + /// Provides a mechanism for creating `Binding`s associated with `Action` cases in a `Store`, leveraging `KeyPath`s to reduce duplication and errors. Supports `enum` cases without associated values. + /// - Parameters: + /// - stateKeyPath: The `KeyPath` to the `State` property that this binding wraps. + /// - actionCasePath: The `CasePath` to the `Action` case associated with the `State` property. + func makeBinding(stateKeyPath: KeyPath, actionCasePath: CasePath) -> Binding { + return .init { + self.state[keyPath: stateKeyPath] + } set: { value in + self.send(actionCasePath.embed(())) + } + } + + /// Provides a mechanism for creating `Binding`s based on the existence of a property on the `State`, leveraging `KeyPath`s to reduce duplication and errors. + /// - Parameters: + /// - stateKeyPath: The `KeyPath` to the optional `State` property whose existence determines the wrapped value. + /// - actionCasePath: The `CasePath` to the `Action` case associated with the `State` property. + func makeBinding(stateKeyPath: KeyPath, actionCasePath: CasePath) -> Binding { + return .init { + self.state[keyPath: stateKeyPath] != nil + } set: { value in + self.send(actionCasePath.embed(())) + } + } + + /// Provides a mechanism for creating `Binding`s based on the existence of a property on the `State`, leveraging `KeyPath`s to reduce duplication and errors. + /// - Parameters: + /// - stateKeyPath: The `KeyPath` to the optional `State` property whose existence determines the wrapped value. + /// - actionCasePath: The `CasePath` to the `Action` case associated with the `State` property. + func makeBinding(stateKeyPath: KeyPath, actionCasePath: CasePath) -> Binding { + return .init { + self.state[keyPath: stateKeyPath] != nil + } set: { value in + guard !value else { + return assertionFailure("Unexpectedly received `true` from `makeBinding` Bool convenience setter.") + } + + self.send(actionCasePath.embed(nil)) + } + } + + /// Provides a mechanism for creating `Binding`s that send their value to a `PassthroughSubject`, leveraging `KeyPath`s to reduce duplication and errors. + /// - Parameters: + /// - stateKeyPath: The `KeyPath` to the `State` property that this binding wraps. + /// - publisher: The publisher to send the value to. + func makeBinding(stateKeyPath: KeyPath, publisher: PassthroughSubject) -> Binding { + return .init { + self.state[keyPath: stateKeyPath] + } set: { value in + publisher.send(value) + } + } + + /// Provides a mechanism for creating `Binding`s that send their value to a `CurrentValueSubject`, leveraging `KeyPath`s to reduce duplication and errors. + /// - Parameters: + /// - StateKeyPath: The `KeyPath` to the `State` property that this binding wraps. + /// - publisher: The publisher to send the value to. + func makeBinding(stateKeyPath: KeyPath, publisher: CurrentValueSubject) -> Binding { + return .init { + self.state[keyPath: stateKeyPath] + } set: { value in + publisher.send(value) + } + } +} diff --git a/Sources/ViewStore/Store+Helpers.swift b/Sources/ViewStore/Store+Helpers.swift new file mode 100644 index 0000000..32f45a1 --- /dev/null +++ b/Sources/ViewStore/Store+Helpers.swift @@ -0,0 +1,19 @@ +// +// Store+Helpers.swift +// ViewStore +// +// Created by Kenneth Ackerson on 4/5/23. +// + +import Foundation +import Combine + +extension Store { + func pipeActions(publisher: some Publisher, storingIn cancellables: inout Set) { + publisher + .sink { [weak self] in + self?.send($0) + } + .store(in: &cancellables) + } +} diff --git a/Sources/ViewStore/Store.swift b/Sources/ViewStore/Store.swift new file mode 100644 index 0000000..7ec5371 --- /dev/null +++ b/Sources/ViewStore/Store.swift @@ -0,0 +1,34 @@ +// +// Store.swift +// ViewStore +// +// Created by Michael Liberatore on 5/16/22. +// + +import SwiftUI +import Combine + +/// A store is an `ObservableObject` that allows us to separate business and/or view level logic and the rendering of views in a way that is repeatable, prescriptive, flexible, and testable by default. +public protocol Store: ObservableObject { + + /// A container type for state associated with the corresponding domain. + associatedtype State + + /// Usually represented as an `enum`, `Action` represents any functionality that a store can perform on-demand. + associatedtype Action + + /// Single source of truth that is used to respresent the current state of the domain. + var state: State { get } + + /// A publisher that publishes each state as it changes + var publishedState: AnyPublisher { get } + + /// Single API to perform behaviors or trigger events, usually resulting in updated `state`. + /// - Parameter action: The action to perform. + func send(_ action: Action) +} + +/// Default implementation that allows stores with no actions to send to ignore this function requirement in the protocol. +public extension Store { + func send(_ action: Never) {} +} diff --git a/Sources/ViewStore/ViewStore+BindingAdditions.swift b/Sources/ViewStore/ViewStore+BindingAdditions.swift deleted file mode 100644 index 5dcd2a5..0000000 --- a/Sources/ViewStore/ViewStore+BindingAdditions.swift +++ /dev/null @@ -1,90 +0,0 @@ -// -// ViewStore+BindingAdditions.swift -// ViewStore -// -// Created by Twig on 7/21/22. -// - -import SwiftUI -import CasePaths -import Combine - -/// An extension on `ViewStore` that provides conveniences for creating `Binding`s. -public extension ViewStore { - - /// Provides a mechanism for creating `Binding`s associated with `Action` cases in a `ViewStore`, leveraging `KeyPath`s to reduce duplication and errors. Supports `enum` cases with associated values. - /// - Parameters: - /// - viewStateKeyPath: The `KeyPath` to the `ViewState` property that this binding wraps. - /// - actionCasePath: The `CasePath` to the `Action` case associated with the `ViewState` property. - func makeBinding(viewStateKeyPath: KeyPath, actionCasePath: CasePath) -> Binding { - return .init { - self.viewState[keyPath: viewStateKeyPath] - } set: { value in - self.send(actionCasePath.embed(value)) - } - } - - /// Provides a mechanism for creating `Binding`s associated with `Action` cases in a `ViewStore`, leveraging `KeyPath`s to reduce duplication and errors. Supports `enum` cases without associated values. - /// - Parameters: - /// - viewStateKeyPath: The `KeyPath` to the `ViewState` property that this binding wraps. - /// - actionCasePath: The `CasePath` to the `Action` case associated with the `ViewState` property. - func makeBinding(viewStateKeyPath: KeyPath, actionCasePath: CasePath) -> Binding { - return .init { - self.viewState[keyPath: viewStateKeyPath] - } set: { value in - self.send(actionCasePath.embed(())) - } - } - - /// Provides a mechanism for creating `Binding`s based on the existence of a property on the `ViewState`, leveraging `KeyPath`s to reduce duplication and errors. - /// - Parameters: - /// - viewStateKeyPath: The `KeyPath` to the optional `ViewState` property whose existence determines the wrapped value. - /// - actionCasePath: The `CasePath` to the `Action` case associated with the `ViewState` property. - func makeBinding(viewStateKeyPath: KeyPath, actionCasePath: CasePath) -> Binding { - return .init { - self.viewState[keyPath: viewStateKeyPath] != nil - } set: { value in - self.send(actionCasePath.embed(())) - } - } - - /// Provides a mechanism for creating `Binding`s based on the existence of a property on the `ViewState`, leveraging `KeyPath`s to reduce duplication and errors. - /// - Parameters: - /// - viewStateKeyPath: The `KeyPath` to the optional `ViewState` property whose existence determines the wrapped value. - /// - actionCasePath: The `CasePath` to the `Action` case associated with the `ViewState` property. - func makeBinding(viewStateKeyPath: KeyPath, actionCasePath: CasePath) -> Binding { - return .init { - self.viewState[keyPath: viewStateKeyPath] != nil - } set: { value in - guard !value else { - return assertionFailure("Unexpectedly received `true` from `makeBinding` Bool convenience setter.") - } - - self.send(actionCasePath.embed(nil)) - } - } - - /// Provides a mechanism for creating `Binding`s that send their value to a `PassthroughSubject`, leveraging `KeyPath`s to reduce duplication and errors. - /// - Parameters: - /// - viewStateKeyPath: The `KeyPath` to the `ViewState` property that this binding wraps. - /// - publisher: The publisher to send the value to. - func makeBinding(viewStateKeyPath: KeyPath, publisher: PassthroughSubject) -> Binding { - return .init { - self.viewState[keyPath: viewStateKeyPath] - } set: { value in - publisher.send(value) - } - } - - /// Provides a mechanism for creating `Binding`s that send their value to a `CurrentValueSubject`, leveraging `KeyPath`s to reduce duplication and errors. - /// - Parameters: - /// - viewStateKeyPath: The `KeyPath` to the `ViewState` property that this binding wraps. - /// - publisher: The publisher to send the value to. - func makeBinding(viewStateKeyPath: KeyPath, publisher: CurrentValueSubject) -> Binding { - return .init { - self.viewState[keyPath: viewStateKeyPath] - } set: { value in - publisher.send(value) - } - } -} diff --git a/Sources/ViewStore/ViewStore.swift b/Sources/ViewStore/ViewStore.swift deleted file mode 100644 index ef72957..0000000 --- a/Sources/ViewStore/ViewStore.swift +++ /dev/null @@ -1,30 +0,0 @@ -// -// ViewStore.swift -// ViewStore -// -// Created by Michael Liberatore on 5/16/22. -// - -import SwiftUI - -/// A view store is an `ObservableObject` that allows us to separate view-specific logic and the rendering of a corresponding view in a way that is repeatable, prescriptive, flexible, and testable by default. -public protocol ViewStore: ObservableObject { - - /// A container type for state associated with the corresponding view. - associatedtype ViewState - - /// Usually represented as an `enum`, `Action` represents any functionality that a view store can perform on-demand. - associatedtype Action - - /// Single source of truth for state that is used to populate a corresponding view. - var viewState: ViewState { get } - - /// Single API for the corresponding view to cause the view store perform some functionality, usually resulting in updated `viewState`. - /// - Parameter action: The action to perform. - func send(_ action: Action) -} - -/// Default implementation that allows stores with no actions to send to ignore this function requirement in the protocol. -public extension ViewStore { - func send(_ action: Never) {} -} diff --git a/ViewStore.xcodeproj/project.pbxproj b/ViewStore.xcodeproj/project.pbxproj index 53171da..8a58433 100644 --- a/ViewStore.xcodeproj/project.pbxproj +++ b/ViewStore.xcodeproj/project.pbxproj @@ -13,13 +13,15 @@ 0B0FC066287C6E3D00207496 /* MockItemProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B0FC065287C6E3D00207496 /* MockItemProvider.swift */; }; 0B1979CE283291A700552A4E /* CasePaths in Frameworks */ = {isa = PBXBuildFile; productRef = 0B1979CD283291A700552A4E /* CasePaths */; }; 0B3201F728B3BF6B00AF8884 /* Publisher+CombineLatest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B3201F328B3BF6B00AF8884 /* Publisher+CombineLatest.swift */; }; - 0B3201F828B3BF6B00AF8884 /* ViewStore+BindingAdditions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B3201F428B3BF6B00AF8884 /* ViewStore+BindingAdditions.swift */; }; 0B3201F928B3BF6B00AF8884 /* MainQueueScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B3201F528B3BF6B00AF8884 /* MainQueueScheduler.swift */; }; - 0B3201FA28B3BF6B00AF8884 /* ViewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B3201F628B3BF6B00AF8884 /* ViewStore.swift */; }; 0B77E916287F23C400BC3595 /* Array+Filtering.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B77E915287F23C400BC3595 /* Array+Filtering.swift */; }; 0B92D48328577B7700FF1BDF /* PhotoListOriginal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B92D48228577B7700FF1BDF /* PhotoListOriginal.swift */; }; 3A31E613287F47D800955C37 /* ViewStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A31E612287F47D800955C37 /* ViewStoreTests.swift */; }; - 3AF7CE6E2954BC500045B466 /* MockViewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AF7CE6D2954BC500045B466 /* MockViewStore.swift */; }; + 3A601C4B29E5CCE5008D0BB3 /* Store.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A601C4A29E5CCE5008D0BB3 /* Store.swift */; }; + 3A601C4D29E5CCEC008D0BB3 /* MockStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A601C4C29E5CCEC008D0BB3 /* MockStore.swift */; }; + 3A601C4F29E5CCF3008D0BB3 /* Store+BindingAdditions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A601C4E29E5CCF3008D0BB3 /* Store+BindingAdditions.swift */; }; + 3A601C5129E5CD0B008D0BB3 /* ScopedStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A601C5029E5CD0B008D0BB3 /* ScopedStore.swift */; }; + 3A601C5329E5CD11008D0BB3 /* Store+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A601C5229E5CD11008D0BB3 /* Store+Helpers.swift */; }; F2B5F5B827C7EC0C00FD7831 /* Example.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2B5F5B727C7EC0C00FD7831 /* Example.swift */; }; F2B5F5BC27C7EC0E00FD7831 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F2B5F5BB27C7EC0E00FD7831 /* Assets.xcassets */; }; F2B5F5BF27C7EC0E00FD7831 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F2B5F5BE27C7EC0E00FD7831 /* Preview Assets.xcassets */; }; @@ -46,14 +48,16 @@ 0B0FC061287C6B7F00207496 /* thumbnail-2.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "thumbnail-2.png"; sourceTree = ""; }; 0B0FC065287C6E3D00207496 /* MockItemProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockItemProvider.swift; sourceTree = ""; }; 0B3201F328B3BF6B00AF8884 /* Publisher+CombineLatest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Publisher+CombineLatest.swift"; sourceTree = ""; }; - 0B3201F428B3BF6B00AF8884 /* ViewStore+BindingAdditions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ViewStore+BindingAdditions.swift"; sourceTree = ""; }; 0B3201F528B3BF6B00AF8884 /* MainQueueScheduler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MainQueueScheduler.swift; sourceTree = ""; }; - 0B3201F628B3BF6B00AF8884 /* ViewStore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ViewStore.swift; sourceTree = ""; }; 0B77E915287F23C400BC3595 /* Array+Filtering.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Filtering.swift"; sourceTree = ""; }; 0B92D48228577B7700FF1BDF /* PhotoListOriginal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoListOriginal.swift; sourceTree = ""; }; 3A31E610287F47D700955C37 /* ViewStoreTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ViewStoreTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3A31E612287F47D800955C37 /* ViewStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewStoreTests.swift; sourceTree = ""; }; - 3AF7CE6D2954BC500045B466 /* MockViewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockViewStore.swift; sourceTree = ""; }; + 3A601C4A29E5CCE5008D0BB3 /* Store.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Store.swift; sourceTree = ""; }; + 3A601C4C29E5CCEC008D0BB3 /* MockStore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockStore.swift; sourceTree = ""; }; + 3A601C4E29E5CCF3008D0BB3 /* Store+BindingAdditions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Store+BindingAdditions.swift"; sourceTree = ""; }; + 3A601C5029E5CD0B008D0BB3 /* ScopedStore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScopedStore.swift; sourceTree = ""; }; + 3A601C5229E5CD11008D0BB3 /* Store+Helpers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Store+Helpers.swift"; sourceTree = ""; }; F2B5F5B427C7EC0C00FD7831 /* ViewStore.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ViewStore.app; sourceTree = BUILT_PRODUCTS_DIR; }; F2B5F5B727C7EC0C00FD7831 /* Example.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Example.swift; sourceTree = ""; }; F2B5F5BB27C7EC0E00FD7831 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -96,10 +100,12 @@ isa = PBXGroup; children = ( 0B3201F328B3BF6B00AF8884 /* Publisher+CombineLatest.swift */, - 0B3201F428B3BF6B00AF8884 /* ViewStore+BindingAdditions.swift */, 0B3201F528B3BF6B00AF8884 /* MainQueueScheduler.swift */, - 0B3201F628B3BF6B00AF8884 /* ViewStore.swift */, - 3AF7CE6D2954BC500045B466 /* MockViewStore.swift */, + 3A601C4A29E5CCE5008D0BB3 /* Store.swift */, + 3A601C4C29E5CCEC008D0BB3 /* MockStore.swift */, + 3A601C4E29E5CCF3008D0BB3 /* Store+BindingAdditions.swift */, + 3A601C5029E5CD0B008D0BB3 /* ScopedStore.swift */, + 3A601C5229E5CD11008D0BB3 /* Store+Helpers.swift */, ); path = ViewStore; sourceTree = ""; @@ -304,17 +310,19 @@ files = ( 0B77E916287F23C400BC3595 /* Array+Filtering.swift in Sources */, F2B5F5D527C7FB0600FD7831 /* Photo.swift in Sources */, - 0B3201FA28B3BF6B00AF8884 /* ViewStore.swift in Sources */, 0B3201F728B3BF6B00AF8884 /* Publisher+CombineLatest.swift in Sources */, - 3AF7CE6E2954BC500045B466 /* MockViewStore.swift in Sources */, F2B5F5D627C7FB0600FD7831 /* PhotoListViewStore.swift in Sources */, - 0B3201F828B3BF6B00AF8884 /* ViewStore+BindingAdditions.swift in Sources */, + 3A601C5129E5CD0B008D0BB3 /* ScopedStore.swift in Sources */, + 3A601C4B29E5CCE5008D0BB3 /* Store.swift in Sources */, F2B5F5D427C7FB0600FD7831 /* PhotoList.swift in Sources */, + 3A601C4F29E5CCF3008D0BB3 /* Store+BindingAdditions.swift in Sources */, + 3A601C5329E5CD11008D0BB3 /* Store+Helpers.swift in Sources */, F2B5F5CE27C7FA0F00FD7831 /* APIRequest.swift in Sources */, 0B92D48328577B7700FF1BDF /* PhotoListOriginal.swift in Sources */, 0B3201F928B3BF6B00AF8884 /* MainQueueScheduler.swift in Sources */, 0B0FC066287C6E3D00207496 /* MockItemProvider.swift in Sources */, F2B5F5B827C7EC0C00FD7831 /* Example.swift in Sources */, + 3A601C4D29E5CCEC008D0BB3 /* MockStore.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; From 215e5d28565772ab0ada3e3fe83dd841a0474a40 Mon Sep 17 00:00:00 2001 From: Ken Ackerson Date: Tue, 11 Apr 2023 13:20:43 -0400 Subject: [PATCH 02/18] Fixing doc --- Example/Photos/PhotoList.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Example/Photos/PhotoList.swift b/Example/Photos/PhotoList.swift index d79f715..187a419 100644 --- a/Example/Photos/PhotoList.swift +++ b/Example/Photos/PhotoList.swift @@ -15,7 +15,7 @@ struct PhotoList: View { /// Creates a new `PhotoList`. /// - Parameters: - /// - store: The `Store` that drives + /// - store: The `Store` that drives this view. init(store: @autoclosure @escaping () -> Store) { self._store = StateObject(wrappedValue: store()) } From e628a81156e6d3b01d7401940a13f767a7ebee12 Mon Sep 17 00:00:00 2001 From: Ken Ackerson Date: Tue, 11 Apr 2023 13:22:13 -0400 Subject: [PATCH 03/18] Fix tests --- .../{ViewStoreTests.swift => StoreTests.swift} | 10 +++++----- ViewStore.xcodeproj/project.pbxproj | 8 ++++---- 2 files changed, 9 insertions(+), 9 deletions(-) rename ExampleTests/{ViewStoreTests.swift => StoreTests.swift} (85%) diff --git a/ExampleTests/ViewStoreTests.swift b/ExampleTests/StoreTests.swift similarity index 85% rename from ExampleTests/ViewStoreTests.swift rename to ExampleTests/StoreTests.swift index 6c69ace..04a5e59 100644 --- a/ExampleTests/ViewStoreTests.swift +++ b/ExampleTests/StoreTests.swift @@ -1,5 +1,5 @@ // -// ViewStoreTests.swift +// StoreTests.swift // ViewStoreTests // // Created by Kenneth Ackerson on 7/13/22. @@ -10,7 +10,7 @@ import XCTest @testable import Provider import Combine -final class ViewStoreTests: XCTestCase { +final class StoreTests: XCTestCase { func testToggleShowsPhotoCount() throws { let mock = MockItemProvider(photosCount: 3) @@ -21,7 +21,7 @@ final class ViewStoreTests: XCTestCase { scheduler.advance() - XCTAssertEqual(vs.viewState.showsPhotoCount, true) + XCTAssertEqual(vs.state.showsPhotoCount, true) } func testSearchProperlyFiltersByTitle() throws { @@ -33,7 +33,7 @@ final class ViewStoreTests: XCTestCase { scheduler.advance(by: 1) - switch vs.viewState.status { + switch vs.state.status { case .error(_), .loading: XCTFail() case let .content(photos): @@ -48,6 +48,6 @@ final class ViewStoreTests: XCTestCase { let vs = PhotoListViewStore(provider: mock, scheduler: MainQueueScheduler(type: .synchronous)) vs.send(.search("2")) - XCTAssertEqual(vs.viewState.searchText, "2") + XCTAssertEqual(vs.state.searchText, "2") } } diff --git a/ViewStore.xcodeproj/project.pbxproj b/ViewStore.xcodeproj/project.pbxproj index 8a58433..e7df62c 100644 --- a/ViewStore.xcodeproj/project.pbxproj +++ b/ViewStore.xcodeproj/project.pbxproj @@ -16,7 +16,7 @@ 0B3201F928B3BF6B00AF8884 /* MainQueueScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B3201F528B3BF6B00AF8884 /* MainQueueScheduler.swift */; }; 0B77E916287F23C400BC3595 /* Array+Filtering.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B77E915287F23C400BC3595 /* Array+Filtering.swift */; }; 0B92D48328577B7700FF1BDF /* PhotoListOriginal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B92D48228577B7700FF1BDF /* PhotoListOriginal.swift */; }; - 3A31E613287F47D800955C37 /* ViewStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A31E612287F47D800955C37 /* ViewStoreTests.swift */; }; + 3A31E613287F47D800955C37 /* StoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A31E612287F47D800955C37 /* StoreTests.swift */; }; 3A601C4B29E5CCE5008D0BB3 /* Store.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A601C4A29E5CCE5008D0BB3 /* Store.swift */; }; 3A601C4D29E5CCEC008D0BB3 /* MockStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A601C4C29E5CCEC008D0BB3 /* MockStore.swift */; }; 3A601C4F29E5CCF3008D0BB3 /* Store+BindingAdditions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A601C4E29E5CCF3008D0BB3 /* Store+BindingAdditions.swift */; }; @@ -52,7 +52,7 @@ 0B77E915287F23C400BC3595 /* Array+Filtering.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Filtering.swift"; sourceTree = ""; }; 0B92D48228577B7700FF1BDF /* PhotoListOriginal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoListOriginal.swift; sourceTree = ""; }; 3A31E610287F47D700955C37 /* ViewStoreTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ViewStoreTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 3A31E612287F47D800955C37 /* ViewStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewStoreTests.swift; sourceTree = ""; }; + 3A31E612287F47D800955C37 /* StoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreTests.swift; sourceTree = ""; }; 3A601C4A29E5CCE5008D0BB3 /* Store.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Store.swift; sourceTree = ""; }; 3A601C4C29E5CCEC008D0BB3 /* MockStore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockStore.swift; sourceTree = ""; }; 3A601C4E29E5CCF3008D0BB3 /* Store+BindingAdditions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Store+BindingAdditions.swift"; sourceTree = ""; }; @@ -113,7 +113,7 @@ 3A31E611287F47D800955C37 /* ExampleTests */ = { isa = PBXGroup; children = ( - 3A31E612287F47D800955C37 /* ViewStoreTests.swift */, + 3A31E612287F47D800955C37 /* StoreTests.swift */, ); path = ExampleTests; sourceTree = ""; @@ -300,7 +300,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 3A31E613287F47D800955C37 /* ViewStoreTests.swift in Sources */, + 3A31E613287F47D800955C37 /* StoreTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; From 356becccb0fd112674be4f4dc2ed678834ddd6d2 Mon Sep 17 00:00:00 2001 From: Ken Ackerson Date: Tue, 11 Apr 2023 13:23:21 -0400 Subject: [PATCH 04/18] Changing directory name --- Sources/{ViewStore => Store}/MainQueueScheduler.swift | 0 Sources/{ViewStore => Store}/MockStore.swift | 0 Sources/{ViewStore => Store}/Publisher+CombineLatest.swift | 0 Sources/{ViewStore => Store}/ScopedStore.swift | 0 Sources/{ViewStore => Store}/Store+BindingAdditions.swift | 0 Sources/{ViewStore => Store}/Store+Helpers.swift | 0 Sources/{ViewStore => Store}/Store.swift | 0 ViewStore.xcodeproj/project.pbxproj | 6 +++--- 8 files changed, 3 insertions(+), 3 deletions(-) rename Sources/{ViewStore => Store}/MainQueueScheduler.swift (100%) rename Sources/{ViewStore => Store}/MockStore.swift (100%) rename Sources/{ViewStore => Store}/Publisher+CombineLatest.swift (100%) rename Sources/{ViewStore => Store}/ScopedStore.swift (100%) rename Sources/{ViewStore => Store}/Store+BindingAdditions.swift (100%) rename Sources/{ViewStore => Store}/Store+Helpers.swift (100%) rename Sources/{ViewStore => Store}/Store.swift (100%) diff --git a/Sources/ViewStore/MainQueueScheduler.swift b/Sources/Store/MainQueueScheduler.swift similarity index 100% rename from Sources/ViewStore/MainQueueScheduler.swift rename to Sources/Store/MainQueueScheduler.swift diff --git a/Sources/ViewStore/MockStore.swift b/Sources/Store/MockStore.swift similarity index 100% rename from Sources/ViewStore/MockStore.swift rename to Sources/Store/MockStore.swift diff --git a/Sources/ViewStore/Publisher+CombineLatest.swift b/Sources/Store/Publisher+CombineLatest.swift similarity index 100% rename from Sources/ViewStore/Publisher+CombineLatest.swift rename to Sources/Store/Publisher+CombineLatest.swift diff --git a/Sources/ViewStore/ScopedStore.swift b/Sources/Store/ScopedStore.swift similarity index 100% rename from Sources/ViewStore/ScopedStore.swift rename to Sources/Store/ScopedStore.swift diff --git a/Sources/ViewStore/Store+BindingAdditions.swift b/Sources/Store/Store+BindingAdditions.swift similarity index 100% rename from Sources/ViewStore/Store+BindingAdditions.swift rename to Sources/Store/Store+BindingAdditions.swift diff --git a/Sources/ViewStore/Store+Helpers.swift b/Sources/Store/Store+Helpers.swift similarity index 100% rename from Sources/ViewStore/Store+Helpers.swift rename to Sources/Store/Store+Helpers.swift diff --git a/Sources/ViewStore/Store.swift b/Sources/Store/Store.swift similarity index 100% rename from Sources/ViewStore/Store.swift rename to Sources/Store/Store.swift diff --git a/ViewStore.xcodeproj/project.pbxproj b/ViewStore.xcodeproj/project.pbxproj index e7df62c..f50f04f 100644 --- a/ViewStore.xcodeproj/project.pbxproj +++ b/ViewStore.xcodeproj/project.pbxproj @@ -91,12 +91,12 @@ 0B3201F128B3BF6B00AF8884 /* Sources */ = { isa = PBXGroup; children = ( - 0B3201F228B3BF6B00AF8884 /* ViewStore */, + 0B3201F228B3BF6B00AF8884 /* Store */, ); path = Sources; sourceTree = ""; }; - 0B3201F228B3BF6B00AF8884 /* ViewStore */ = { + 0B3201F228B3BF6B00AF8884 /* Store */ = { isa = PBXGroup; children = ( 0B3201F328B3BF6B00AF8884 /* Publisher+CombineLatest.swift */, @@ -107,7 +107,7 @@ 3A601C5029E5CD0B008D0BB3 /* ScopedStore.swift */, 3A601C5229E5CD11008D0BB3 /* Store+Helpers.swift */, ); - path = ViewStore; + path = Store; sourceTree = ""; }; 3A31E611287F47D800955C37 /* ExampleTests */ = { From 2db29201d11a1bfebf262a48b46d2ed8c7ee8ded Mon Sep 17 00:00:00 2001 From: Ken Ackerson Date: Tue, 11 Apr 2023 13:34:39 -0400 Subject: [PATCH 05/18] Docs and renaming one initializer argument --- Sources/Store/ScopedStore.swift | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/Sources/Store/ScopedStore.swift b/Sources/Store/ScopedStore.swift index 03386af..d0e1d89 100644 --- a/Sources/Store/ScopedStore.swift +++ b/Sources/Store/ScopedStore.swift @@ -9,6 +9,7 @@ import Foundation import Combine import CasePaths +/// A `Store` that's purpose is to allow clients of it to modify a parent's Store and one of it's sub-stores without having direct access to either store. public final class ScopedStore: Store { @Published public var state: State @@ -18,21 +19,26 @@ public final class ScopedStore: Store { private let action: (Action) -> Void - public init(initial: State, viewStatePub: some Publisher, action: @escaping (Action) -> Void) { + + /// Initializes a new `ScopedStore` + /// - Parameters: + /// - initial: The initial state for this `Store`, likely a copy of whatever the current sub-store's state is now (see the `scoped` function on the `Store` extension for an example) + /// - statePub: The publisher that allows this `ScopedStore` to get the lastest copy of the sub-store's state. + /// - action: A closure to let you pass actions back to a parent `Store`. (see the `scoped` function on the `Store` extension for an example of embedding these into a "sub-action" of a parent Store to forward to a sub-store) + public init(initial: State, statePub: some Publisher, action: @escaping (Action) -> Void) { state = initial self.action = action - viewStatePub.assign(to: &$state) + statePub.assign(to: &$state) } public func send(_ action: Action) { self.action(action) } - } public extension Store { func scoped(stateKeyPath: KeyPath, actionCasePath: CasePath) -> any Store { - return ScopedStore(initial: state[keyPath: stateKeyPath], viewStatePub: publishedState.map(stateKeyPath), action: { self.send(actionCasePath.embed($0)) }) + return ScopedStore(initial: state[keyPath: stateKeyPath], statePub: publishedState.map(stateKeyPath), action: { self.send(actionCasePath.embed($0)) }) } } From eeff51922008b4bec665df5c3bbfe07841a5d670 Mon Sep 17 00:00:00 2001 From: Ken Ackerson Date: Tue, 11 Apr 2023 13:49:21 -0400 Subject: [PATCH 06/18] Adding docs --- Sources/Store/ScopedStore.swift | 41 +++++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/Sources/Store/ScopedStore.swift b/Sources/Store/ScopedStore.swift index d0e1d89..e401100 100644 --- a/Sources/Store/ScopedStore.swift +++ b/Sources/Store/ScopedStore.swift @@ -19,7 +19,6 @@ public final class ScopedStore: Store { private let action: (Action) -> Void - /// Initializes a new `ScopedStore` /// - Parameters: /// - initial: The initial state for this `Store`, likely a copy of whatever the current sub-store's state is now (see the `scoped` function on the `Store` extension for an example) @@ -37,8 +36,46 @@ public final class ScopedStore: Store { } public extension Store { + /// Creates a `ScopedStore` that uses a keypath to a property on the current `Store`s state and a parent `Store`s action that has a subaction as its associated value. + ///``` + ///typealias ParentStoreType = Store + /// + ///final class ParentStore: Store { + /// struct State { + /// let substate: Substore.State + /// } + /// + /// enum Action { + /// case subaction(Substore.Action) + /// } + /// + /// private let substore = Substore() + /// + /// init() { + /// substore.$state + /// .map(State.init) + /// .assign(&$state) + /// } + /// + /// } + /// + ///extension ParentStoreType { + /// var substore: SubstoreType { + /// return scoped(stateKeyPath: /.substate, actionCasePath: \Action.subaction) + /// } + /// } + /// + ///typealias SubstoreType = Store + ///final class Substore: Store { + /// // Store logic and properties to manage some state + ///} + ///``` + /// + /// - Parameters: + /// - stateKeyPath: The keypath to the property on the Parent's `State` that is managed by the substore. + /// - actionCasePath: The case path to an action on the Parent's `Store` that has the substore's action as the associated value that forwards to the substore. + /// - Returns: A `Store` that is scoped to the specified state and action. func scoped(stateKeyPath: KeyPath, actionCasePath: CasePath) -> any Store { return ScopedStore(initial: state[keyPath: stateKeyPath], statePub: publishedState.map(stateKeyPath), action: { self.send(actionCasePath.embed($0)) }) } } - From 9a6f299f69ab04838ab593f3148741ff43383fd0 Mon Sep 17 00:00:00 2001 From: Ken Ackerson Date: Tue, 11 Apr 2023 13:51:43 -0400 Subject: [PATCH 07/18] spacing stuff in doc comment --- Sources/Store/ScopedStore.swift | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/Sources/Store/ScopedStore.swift b/Sources/Store/ScopedStore.swift index e401100..b41c64e 100644 --- a/Sources/Store/ScopedStore.swift +++ b/Sources/Store/ScopedStore.swift @@ -38,9 +38,9 @@ public final class ScopedStore: Store { public extension Store { /// Creates a `ScopedStore` that uses a keypath to a property on the current `Store`s state and a parent `Store`s action that has a subaction as its associated value. ///``` - ///typealias ParentStoreType = Store + /// typealias ParentStoreType = Store /// - ///final class ParentStore: Store { + /// final class ParentStore: Store { /// struct State { /// let substate: Substore.State /// } @@ -53,22 +53,22 @@ public extension Store { /// /// init() { /// substore.$state - /// .map(State.init) - /// .assign(&$state) + /// .map(State.init) + /// .assign(&$state) /// } /// /// } /// - ///extension ParentStoreType { + /// extension ParentStoreType { /// var substore: SubstoreType { /// return scoped(stateKeyPath: /.substate, actionCasePath: \Action.subaction) /// } /// } /// - ///typealias SubstoreType = Store - ///final class Substore: Store { + /// typealias SubstoreType = Store + /// final class Substore: Store { /// // Store logic and properties to manage some state - ///} + /// } ///``` /// /// - Parameters: @@ -79,3 +79,4 @@ public extension Store { return ScopedStore(initial: state[keyPath: stateKeyPath], statePub: publishedState.map(stateKeyPath), action: { self.send(actionCasePath.embed($0)) }) } } + From e3852db6f5391dde68648f6a9120e6d3fe5345d0 Mon Sep 17 00:00:00 2001 From: Ken Ackerson Date: Tue, 11 Apr 2023 13:52:13 -0400 Subject: [PATCH 08/18] adding newline --- Sources/Store/ScopedStore.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/Store/ScopedStore.swift b/Sources/Store/ScopedStore.swift index b41c64e..9eb10c6 100644 --- a/Sources/Store/ScopedStore.swift +++ b/Sources/Store/ScopedStore.swift @@ -37,6 +37,7 @@ public final class ScopedStore: Store { public extension Store { /// Creates a `ScopedStore` that uses a keypath to a property on the current `Store`s state and a parent `Store`s action that has a subaction as its associated value. + /// ///``` /// typealias ParentStoreType = Store /// From fa726277f523b300673c24d3f76336f542818459 Mon Sep 17 00:00:00 2001 From: Ken Ackerson Date: Tue, 11 Apr 2023 13:54:21 -0400 Subject: [PATCH 09/18] adding docs to the helper function on Store --- Sources/Store/Store+Helpers.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Sources/Store/Store+Helpers.swift b/Sources/Store/Store+Helpers.swift index 32f45a1..e85a107 100644 --- a/Sources/Store/Store+Helpers.swift +++ b/Sources/Store/Store+Helpers.swift @@ -9,6 +9,11 @@ import Foundation import Combine extension Store { + + /// Takes a publisher of actions and executes them as they come in + /// - Parameters: + /// - publisher: The publisher of actions to execute as they come in. + /// - cancellables: The set of cancellables to store into. func pipeActions(publisher: some Publisher, storingIn cancellables: inout Set) { publisher .sink { [weak self] in From 7f995f4736f335dd995e78752150ff7d65b21a20 Mon Sep 17 00:00:00 2001 From: Kenneth Ackerson Date: Tue, 11 Apr 2023 13:59:25 -0400 Subject: [PATCH 10/18] Update Example/Photos/PhotoListViewStore.swift Co-authored-by: Michael Liberatore --- Example/Photos/PhotoListViewStore.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Example/Photos/PhotoListViewStore.swift b/Example/Photos/PhotoListViewStore.swift index 6cf3fb6..cc2508c 100644 --- a/Example/Photos/PhotoListViewStore.swift +++ b/Example/Photos/PhotoListViewStore.swift @@ -61,7 +61,7 @@ final class PhotoListViewStore: Store { /// Creates a new `PhotoListViewStore` /// - Parameters: /// - provider: The provider responsible for fetching photos. - /// - scheduler: Determines how state updates are scheduled to be delivered in the view store. Defaults to `default`, which asynchronously schedules updates on the main queue. + /// - scheduler: Determines how state updates are scheduled to be delivered in the store. Defaults to `default`, which asynchronously schedules updates on the main queue. init(provider: Provider, scheduler: MainQueueScheduler = .init(type: .default)) { self.provider = provider let showsPhotosCountPublisher = self.showsPhotosCountPublisher.prepend(State.initial.showsPhotoCount) From 68499312e0f84ccc1bae33809072439b1dca2bc2 Mon Sep 17 00:00:00 2001 From: Ken Ackerson Date: Tue, 11 Apr 2023 13:59:36 -0400 Subject: [PATCH 11/18] // MARK: --- Sources/Store/ScopedStore.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Sources/Store/ScopedStore.swift b/Sources/Store/ScopedStore.swift index 9eb10c6..6f0d12a 100644 --- a/Sources/Store/ScopedStore.swift +++ b/Sources/Store/ScopedStore.swift @@ -11,12 +11,17 @@ import CasePaths /// A `Store` that's purpose is to allow clients of it to modify a parent's Store and one of it's sub-stores without having direct access to either store. public final class ScopedStore: Store { + + // MARK: - Store + @Published public var state: State public var publishedState: AnyPublisher { return $state.eraseToAnyPublisher() } + // MARK: - ScopedStore + private let action: (Action) -> Void /// Initializes a new `ScopedStore` @@ -29,6 +34,8 @@ public final class ScopedStore: Store { self.action = action statePub.assign(to: &$state) } + + // MARK: - Store public func send(_ action: Action) { self.action(action) From d06a6c95b8cdbf53b07001fd56480c9974337016 Mon Sep 17 00:00:00 2001 From: Kenneth Ackerson Date: Tue, 11 Apr 2023 14:00:09 -0400 Subject: [PATCH 12/18] Update Sources/Store/Store.swift Co-authored-by: Michael Liberatore --- Sources/Store/Store.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Store/Store.swift b/Sources/Store/Store.swift index 7ec5371..8eea773 100644 --- a/Sources/Store/Store.swift +++ b/Sources/Store/Store.swift @@ -20,7 +20,7 @@ public protocol Store: ObservableObject { /// Single source of truth that is used to respresent the current state of the domain. var state: State { get } - /// A publisher that publishes each state as it changes + /// A publisher that publishes each state as it changes. var publishedState: AnyPublisher { get } /// Single API to perform behaviors or trigger events, usually resulting in updated `state`. From b0079522ccac7197cbf8b2b4c1666a84c27737de Mon Sep 17 00:00:00 2001 From: Kenneth Ackerson Date: Tue, 11 Apr 2023 14:00:33 -0400 Subject: [PATCH 13/18] Update Sources/Store/Store+Helpers.swift Co-authored-by: Michael Liberatore --- Sources/Store/Store+Helpers.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Store/Store+Helpers.swift b/Sources/Store/Store+Helpers.swift index e85a107..b15699c 100644 --- a/Sources/Store/Store+Helpers.swift +++ b/Sources/Store/Store+Helpers.swift @@ -14,7 +14,7 @@ extension Store { /// - Parameters: /// - publisher: The publisher of actions to execute as they come in. /// - cancellables: The set of cancellables to store into. - func pipeActions(publisher: some Publisher, storingIn cancellables: inout Set) { + func pipeActions(publisher: some Publisher, storeIn cancellables: inout Set) { publisher .sink { [weak self] in self?.send($0) From 651dad0f22957fb0aa8b48b7b52f5219097a0e65 Mon Sep 17 00:00:00 2001 From: Ken Ackerson Date: Tue, 11 Apr 2023 14:01:53 -0400 Subject: [PATCH 14/18] Adding `MARK` to MockStore --- Sources/Store/MockStore.swift | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Sources/Store/MockStore.swift b/Sources/Store/MockStore.swift index db4c61c..1336b22 100644 --- a/Sources/Store/MockStore.swift +++ b/Sources/Store/MockStore.swift @@ -10,15 +10,22 @@ import Combine /// A generic object conforming to `Store` that simply returns the passed-in state. Useful in SwiftUI previews. public final class MockStore: Store { + + // MARK: - Store + public var publishedState: AnyPublisher { return Just(state).eraseToAnyPublisher() } - + public var state: State + // MARK: - MockStore + public init(state: State) { self.state = state } + // MARK: - Store + public func send(_ action: Action) {} } From ca1be39211b10f4bc13582ffafc0f08936fc2737 Mon Sep 17 00:00:00 2001 From: Kenneth Ackerson Date: Tue, 11 Apr 2023 14:02:15 -0400 Subject: [PATCH 15/18] Update Sources/Store/ScopedStore.swift Co-authored-by: Michael Liberatore --- Sources/Store/ScopedStore.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/Store/ScopedStore.swift b/Sources/Store/ScopedStore.swift index 6f0d12a..ba8bb4e 100644 --- a/Sources/Store/ScopedStore.swift +++ b/Sources/Store/ScopedStore.swift @@ -24,11 +24,11 @@ public final class ScopedStore: Store { private let action: (Action) -> Void - /// Initializes a new `ScopedStore` + /// Initializes a new `ScopedStore`. /// - Parameters: - /// - initial: The initial state for this `Store`, likely a copy of whatever the current sub-store's state is now (see the `scoped` function on the `Store` extension for an example) + /// - initial: The initial state for this `Store`, likely a copy of whatever the current sub-store's state is now (see the `scoped` function on the `Store` extension for an example). /// - statePub: The publisher that allows this `ScopedStore` to get the lastest copy of the sub-store's state. - /// - action: A closure to let you pass actions back to a parent `Store`. (see the `scoped` function on the `Store` extension for an example of embedding these into a "sub-action" of a parent Store to forward to a sub-store) + /// - action: A closure to let you pass actions back to a parent `Store`. (see the `scoped` function on the `Store` extension for an example of embedding these into a "sub-action" of a parent Store to forward to a sub-store). public init(initial: State, statePub: some Publisher, action: @escaping (Action) -> Void) { state = initial self.action = action From b94c464165e5b69e846384b93e7b8140ed9f2ded Mon Sep 17 00:00:00 2001 From: Ken Ackerson Date: Tue, 11 Apr 2023 14:03:00 -0400 Subject: [PATCH 16/18] Updating to `statePublisher` --- Sources/Store/ScopedStore.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/Store/ScopedStore.swift b/Sources/Store/ScopedStore.swift index ba8bb4e..fe75b8b 100644 --- a/Sources/Store/ScopedStore.swift +++ b/Sources/Store/ScopedStore.swift @@ -27,12 +27,12 @@ public final class ScopedStore: Store { /// Initializes a new `ScopedStore`. /// - Parameters: /// - initial: The initial state for this `Store`, likely a copy of whatever the current sub-store's state is now (see the `scoped` function on the `Store` extension for an example). - /// - statePub: The publisher that allows this `ScopedStore` to get the lastest copy of the sub-store's state. + /// - statePublisher: The publisher that allows this `ScopedStore` to get the lastest copy of the sub-store's state. /// - action: A closure to let you pass actions back to a parent `Store`. (see the `scoped` function on the `Store` extension for an example of embedding these into a "sub-action" of a parent Store to forward to a sub-store). - public init(initial: State, statePub: some Publisher, action: @escaping (Action) -> Void) { + public init(initial: State, statePublisher: some Publisher, action: @escaping (Action) -> Void) { state = initial self.action = action - statePub.assign(to: &$state) + statePublisher.assign(to: &$state) } // MARK: - Store @@ -84,7 +84,7 @@ public extension Store { /// - actionCasePath: The case path to an action on the Parent's `Store` that has the substore's action as the associated value that forwards to the substore. /// - Returns: A `Store` that is scoped to the specified state and action. func scoped(stateKeyPath: KeyPath, actionCasePath: CasePath) -> any Store { - return ScopedStore(initial: state[keyPath: stateKeyPath], statePub: publishedState.map(stateKeyPath), action: { self.send(actionCasePath.embed($0)) }) + return ScopedStore(initial: state[keyPath: stateKeyPath], statePublisher: publishedState.map(stateKeyPath), action: { self.send(actionCasePath.embed($0)) }) } } From 774cc150f609cfa3fbab8a7a559ee8d96b3e2f9b Mon Sep 17 00:00:00 2001 From: Kenneth Ackerson Date: Tue, 11 Apr 2023 14:03:29 -0400 Subject: [PATCH 17/18] Update Sources/Store/ScopedStore.swift Co-authored-by: Michael Liberatore --- Sources/Store/ScopedStore.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/Store/ScopedStore.swift b/Sources/Store/ScopedStore.swift index fe75b8b..7995bf2 100644 --- a/Sources/Store/ScopedStore.swift +++ b/Sources/Store/ScopedStore.swift @@ -45,7 +45,7 @@ public final class ScopedStore: Store { public extension Store { /// Creates a `ScopedStore` that uses a keypath to a property on the current `Store`s state and a parent `Store`s action that has a subaction as its associated value. /// - ///``` + /// ``` /// typealias ParentStoreType = Store /// /// final class ParentStore: Store { @@ -77,7 +77,7 @@ public extension Store { /// final class Substore: Store { /// // Store logic and properties to manage some state /// } - ///``` + /// ``` /// /// - Parameters: /// - stateKeyPath: The keypath to the property on the Parent's `State` that is managed by the substore. From 5aa5c969c8866122a9c3d1cce6f4aae7b8dccd16 Mon Sep 17 00:00:00 2001 From: Kenneth Ackerson Date: Tue, 11 Apr 2023 14:03:41 -0400 Subject: [PATCH 18/18] Update Example/Photos/PhotoListViewStore.swift Co-authored-by: Michael Liberatore --- Example/Photos/PhotoListViewStore.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Example/Photos/PhotoListViewStore.swift b/Example/Photos/PhotoListViewStore.swift index cc2508c..7d929c4 100644 --- a/Example/Photos/PhotoListViewStore.swift +++ b/Example/Photos/PhotoListViewStore.swift @@ -85,7 +85,7 @@ final class PhotoListViewStore: Store { .assign(to: &$state) } - // MARK: - ViewStore + // MARK: - Store func send(_ action: Action) { switch action {