Skip to content

Commit

Permalink
Merge pull request #14 from Lickability/kpa/naming-changes-view-store…
Browse files Browse the repository at this point in the history
…-store

Name changes from ViewStore -> Store
  • Loading branch information
Pearapps authored Apr 11, 2023
2 parents 0d06df8 + 5aa5c96 commit 888cec4
Show file tree
Hide file tree
Showing 14 changed files with 330 additions and 188 deletions.
14 changes: 7 additions & 7 deletions Example/Photos/PhotoList.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Store: PhotoListViewStoreType>: View {

@StateObject private var store: Store

/// Creates a new `PhotoList`.
/// - Parameters:
/// - store: The `ViewStore` that drives
/// - store: The `Store` that drives this view.
init(store: @autoclosure @escaping () -> Store) {
self._store = StateObject(wrappedValue: store())
}
Expand All @@ -25,7 +25,7 @@ struct PhotoList<Store: PhotoListViewStoreType>: View {
var body: some View {
NavigationView {
ZStack {
switch store.viewState.status {
switch store.state.status {
case .loading:
ProgressView()
.progressViewStyle(.circular)
Expand All @@ -48,7 +48,7 @@ struct PhotoList<Store: PhotoListViewStoreType>: View {
}
} header: {
Toggle("Show Count", isOn: store.showsPhotoCount)
.animation(.easeInOut, value: store.viewState.showsPhotoCount)
.animation(.easeInOut, value: store.state.showsPhotoCount)
}
}
case let .error(error):
Expand All @@ -60,16 +60,16 @@ struct PhotoList<Store: PhotoListViewStoreType>: View {

}
.navigationBarTitleDisplayMode(.inline)
.navigationTitle(store.viewState.navigationTitle)
.navigationTitle(store.state.navigationTitle)
.searchable(text: store.searchText, placement: .navigationBarDrawer(displayMode: .always))
}
}
}

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))
}
}
40 changes: 22 additions & 18 deletions Example/Photos/PhotoListViewStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,29 +11,29 @@ import Combine
import SwiftUI
import CasePaths

typealias PhotoListViewStoreType = ViewStore<PhotoListViewStore.ViewState, PhotoListViewStore.Action>
typealias PhotoListViewStoreType = Store<PhotoListViewStore.State, PhotoListViewStore.Action>

/// 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
case content([Photo])
}

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
Expand All @@ -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<State, Never> {
return $state.eraseToAnyPublisher()
}

// MARK: - PhotoListViewStore

Expand All @@ -57,12 +61,12 @@ final class PhotoListViewStore: ViewStore {
/// 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(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
Expand All @@ -71,17 +75,17 @@ 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
// MARK: - Store

func send(_ action: Action) {
switch action {
Expand All @@ -97,17 +101,17 @@ extension PhotoListViewStoreType {
var showsPhotoCount: Binding<Bool> {
//
// return Binding<Bool> {
// 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<String> {
makeBinding(viewStateKeyPath: \.searchText, actionCasePath: /Action.search)
makeBinding(stateKeyPath: \.searchText, actionCasePath: /Action.search)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//
// ViewStoreTests.swift
// StoreTests.swift
// ViewStoreTests
//
// Created by Kenneth Ackerson on 7/13/22.
Expand All @@ -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)
Expand All @@ -21,7 +21,7 @@ final class ViewStoreTests: XCTestCase {

scheduler.advance()

XCTAssertEqual(vs.viewState.showsPhotoCount, true)
XCTAssertEqual(vs.state.showsPhotoCount, true)
}

func testSearchProperlyFiltersByTitle() throws {
Expand All @@ -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):
Expand All @@ -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")
}
}
File renamed without changes.
31 changes: 31 additions & 0 deletions Sources/Store/MockStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
//
// 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<State, Action>: Store {

// MARK: - Store

public var publishedState: AnyPublisher<State, Never> {
return Just(state).eraseToAnyPublisher()
}

public var state: State

// MARK: - MockStore

public init(state: State) {
self.state = state
}

// MARK: - Store

public func send(_ action: Action) {}
}
File renamed without changes.
90 changes: 90 additions & 0 deletions Sources/Store/ScopedStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
//
// ScopedStore.swift
// ViewStore
//
// Created by Kenneth Ackerson on 4/3/23.
//

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<State, Action>: Store {

// MARK: - Store

@Published public var state: State

public var publishedState: AnyPublisher<State, Never> {
return $state.eraseToAnyPublisher()
}

// MARK: - ScopedStore

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).
/// - 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, statePublisher: some Publisher<State, Never>, action: @escaping (Action) -> Void) {
state = initial
self.action = action
statePublisher.assign(to: &$state)
}

// MARK: - Store

public func send(_ action: Action) {
self.action(action)
}
}

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<ParentStore.State, ParentStore.Action>
///
/// 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<Substore.State, Substore.Action>
/// 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<Substate, Subaction>(stateKeyPath: KeyPath<State, Substate>, actionCasePath: CasePath<Action, Subaction>) -> any Store<Substate, Subaction> {
return ScopedStore(initial: state[keyPath: stateKeyPath], statePublisher: publishedState.map(stateKeyPath), action: { self.send(actionCasePath.embed($0)) })
}
}

Loading

0 comments on commit 888cec4

Please sign in to comment.