-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #13 from Lickability/kpa/testing-composibility-sha…
…red-state-and-viewstore Shared state composable ViewStore example
- Loading branch information
Showing
12 changed files
with
581 additions
and
22 deletions.
There are no files selected for viewing
File renamed without changes.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
// | ||
// Banner.swift | ||
// ViewStore | ||
// | ||
// Created by Kenneth Ackerson on 4/11/23. | ||
// | ||
|
||
import Foundation | ||
|
||
/// Container for text intended to be displayed at the top of a screen. | ||
struct Banner { | ||
|
||
/// The text to be displayed. | ||
let title: String | ||
} |
83 changes: 83 additions & 0 deletions
83
Example/Photos/With Stores/Banner Feature/BannerDataStore.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,83 @@ | ||
// | ||
// BannerDataStore.swift | ||
// ViewStore | ||
// | ||
// Created by Kenneth Ackerson on 4/3/23. | ||
// | ||
|
||
import Foundation | ||
import Combine | ||
|
||
typealias BannerDataStoreType = Store<BannerDataStore.State, BannerDataStore.Action> | ||
|
||
/// A `Store` that is responsible for being the source of truth for a `Banner`. This includes updating locally and remotely. Not meant to be used to drive a `View`, but rather meant to be composed into other `Store`s. | ||
final class BannerDataStore: Store { | ||
|
||
// MARK: - Store | ||
|
||
struct State { | ||
|
||
/// Initial state of the banner data store. | ||
static let initial = State(banner: .init(title: "Banner"), networkState: .notStarted) | ||
|
||
/// The source of truth of the banner model object. | ||
let banner: Banner | ||
|
||
/// Networking state of the request to upload a new banner model to the server. | ||
let networkState: MockBannerNetworkStateController.NetworkState | ||
} | ||
|
||
enum Action { | ||
/// Changes the local copy of the banner model synchronously. | ||
case updateBannerLocally(Banner) | ||
|
||
/// Sends the banner to the server and then updates the model locally if it was successful. | ||
case uploadBanner(Banner) | ||
|
||
/// Clears the underlying networking state back to `notStarted`. | ||
case clearNetworkingState | ||
} | ||
|
||
@Published var state: State = BannerDataStore.State.initial | ||
|
||
var publishedState: AnyPublisher<State, Never> { | ||
$state.eraseToAnyPublisher() | ||
} | ||
|
||
// MARK: - BannerDataStore | ||
|
||
private let bannerSubject = PassthroughSubject<Banner, Never>() | ||
private let network: MockBannerNetworkStateController = .init() | ||
private var cancellables = Set<AnyCancellable>() | ||
|
||
/// Creates a new `BannerDataStore` | ||
init() { | ||
|
||
let networkPublisher = network.publisher.prepend(.notStarted) | ||
let additionalActions = networkPublisher.compactMap { $0.banner }.map { Action.updateBannerLocally($0) } | ||
|
||
bannerSubject | ||
.prepend(state.banner) | ||
.combineLatest(network.publisher.prepend(.notStarted)) | ||
.map { banner, networkState in | ||
return State(banner: banner, networkState: networkState) | ||
} | ||
.assign(to: &$state) | ||
|
||
pipeActions(publisher: additionalActions, storeIn: &cancellables) | ||
} | ||
|
||
// MARK: - Store | ||
|
||
func send(_ action: Action) { | ||
switch action { | ||
case .updateBannerLocally(let banner): | ||
bannerSubject.send(banner) | ||
case .uploadBanner(let banner): | ||
network.upload(banner: banner) | ||
case .clearNetworkingState: | ||
network.reset() | ||
} | ||
} | ||
|
||
} |
97 changes: 97 additions & 0 deletions
97
Example/Photos/With Stores/Banner Feature/MockBannerNetworkStateController.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,97 @@ | ||
// | ||
// MockBannerNetworkStateController.swift | ||
// ViewStore | ||
// | ||
// Created by Kenneth Ackerson on 4/3/23. | ||
// | ||
|
||
import Foundation | ||
import Combine | ||
|
||
/// A really contrived fake interface similar to networking state controller for updating `Banner` models on a nonexistent server. | ||
final class MockBannerNetworkStateController { | ||
|
||
/// Represents the state of a network request for a banner. | ||
enum NetworkState { | ||
|
||
/// The network request has not started yet. | ||
case notStarted | ||
|
||
/// The network request is currently in progress. | ||
case inProgress | ||
|
||
/// The network request has finished and resulted in either success or failure. | ||
/// - Parameter Result: A result type containing a `Banner` on success or a `NetworkError` on failure. | ||
case finished(Result<Banner, NetworkError>) | ||
|
||
/// The `Banner` object obtained from a successful network request, if available. | ||
var banner: Banner? { | ||
switch self { | ||
case .inProgress, .notStarted: | ||
return nil | ||
case .finished(let result): | ||
return try? result.get() | ||
} | ||
} | ||
|
||
/// The error obtained from a failed network request, if available. | ||
var error: NetworkError? { | ||
switch self { | ||
case .notStarted, .inProgress: | ||
return nil | ||
case .finished(let result): | ||
do { | ||
_ = try result.get() | ||
return nil | ||
} catch let error as NetworkError { | ||
return error | ||
} catch { | ||
assertionFailure("unhandled error") | ||
return nil | ||
} | ||
} | ||
} | ||
|
||
/// Possible errors that can occur when using this controller. | ||
enum NetworkError: LocalizedError { | ||
|
||
/// A mocked error that is expected. | ||
case intentionalFailure | ||
|
||
// MARK - LocalizedError | ||
|
||
var errorDescription: String? { | ||
switch self { | ||
case .intentionalFailure: | ||
return "This is an expected error used for testing error handling." | ||
} | ||
} | ||
} | ||
} | ||
|
||
/// A publisher that sends updates of the `NetworkState`. | ||
public var publisher: PassthroughSubject<NetworkState, Never> = .init() | ||
|
||
/// Uploads a `Banner` to a fake server. | ||
/// - Parameter banner: The `Banner` to upload. | ||
func upload(banner: Banner) { | ||
self.publisher.send(.inProgress) | ||
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { | ||
|
||
// Pick whether you would like to get a successful (`.finished(.success...`) state or any error for this "network request". | ||
|
||
//self.publisher.send(.finished(.success(banner))) | ||
|
||
self.publisher.send(.finished(.failure(.intentionalFailure))) | ||
|
||
} | ||
|
||
} | ||
|
||
/// Resets the current networking state to `notStarted`. | ||
func reset() { | ||
self.publisher.send(.notStarted) | ||
} | ||
|
||
} |
73 changes: 73 additions & 0 deletions
73
Example/Photos/With Stores/Banner Feature/View Level/BannerUpdateView.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,73 @@ | ||
// | ||
// BannerUpdateView.swift | ||
// ViewStore | ||
// | ||
// Created by Kenneth Ackerson on 4/3/23. | ||
// | ||
|
||
import Foundation | ||
import SwiftUI | ||
import Combine | ||
|
||
/// A really simple view that allows you to type and upload a new Banner. | ||
struct BannerUpdateView<Store: BannerUpdateViewStoreType>: View { | ||
|
||
@Environment(\.dismiss) private var dismiss | ||
|
||
@StateObject private var store: Store | ||
|
||
/// Creates a new `BannerUpdateView`. | ||
/// - Parameter store: The `Store` that drives this view. | ||
init(store: @autoclosure @escaping () -> Store) { | ||
self._store = StateObject(wrappedValue: store()) | ||
} | ||
|
||
var body: some View { | ||
VStack { | ||
|
||
VStack { | ||
Spacer() | ||
|
||
TextField("", text: store.workingTitle) | ||
.multilineTextAlignment(.center) | ||
.padding(10) | ||
.font(.system(size: 36)) | ||
|
||
Spacer() | ||
} | ||
.padding(.horizontal, 30) | ||
|
||
Button { | ||
store.send(.submit) | ||
} label: { | ||
Group { | ||
if store.state.dismissable { | ||
Text("Submit") | ||
} else { | ||
ProgressView() | ||
} | ||
} | ||
.foregroundColor(.white) | ||
.padding(.vertical, 20) | ||
.padding(.horizontal, 40) | ||
.background { | ||
RoundedRectangle(cornerRadius: 10) | ||
.foregroundColor(.blue) | ||
} | ||
} | ||
.disabled(!store.state.dismissable) | ||
.padding(.bottom, 10) | ||
} | ||
.onChange(of: store.state.success) { success in | ||
if success { | ||
dismiss() | ||
} | ||
} | ||
.alert(isPresented: store.isErrorPresented, error: store.state.error) { _ in | ||
|
||
} message: { error in | ||
Text("Error") | ||
} | ||
|
||
} | ||
} |
121 changes: 121 additions & 0 deletions
121
Example/Photos/With Stores/Banner Feature/View Level/BannerUpdateViewStore.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,121 @@ | ||
// | ||
// BannerUpdateViewStore.swift | ||
// ViewStore | ||
// | ||
// Created by Kenneth Ackerson on 4/3/23. | ||
// | ||
|
||
import Foundation | ||
import Combine | ||
import SwiftUI | ||
import CasePaths | ||
|
||
typealias BannerUpdateViewStoreType = Store<BannerUpdateViewStore.State, BannerUpdateViewStore.Action> | ||
|
||
/// A `Store` that drives a view that can update a `Banner` through any `BannerDataStoreType`, and exposes view-specific state such as a working copy of the banner, the possible networking error, etc. | ||
final class BannerUpdateViewStore: Store { | ||
|
||
// MARK: - Store | ||
|
||
/// Represents the state of the `BannerUpdateViewStore` | ||
struct State { | ||
/// Stores the state of the nested `BannerDataStore` | ||
let bannerViewState: BannerDataStore.State | ||
|
||
/// A working copy of the banner being updated, to be uploaded if the `submit` action is sent. | ||
let workingCopy: Banner | ||
|
||
/// Returns true if the network state is not started or finished, false if it's in progress | ||
var dismissable: Bool { | ||
switch bannerViewState.networkState { | ||
case .notStarted, .finished: | ||
return true | ||
case .inProgress: | ||
return false | ||
} | ||
} | ||
|
||
/// Returns true if the network state is finished and the result is successful, false otherwise | ||
var success: Bool { | ||
switch bannerViewState.networkState { | ||
case .notStarted, .inProgress: | ||
return false | ||
case .finished(let result): | ||
return (try? result.get()) != nil | ||
} | ||
} | ||
|
||
// Returns a `NetworkError` if there is an error in the network state when it's finished, otherwise returns nil | ||
var error: MockBannerNetworkStateController.NetworkState.NetworkError? { | ||
return bannerViewState.networkState.error | ||
} | ||
} | ||
|
||
enum Action { | ||
/// Action to update the title of the banner with a given string | ||
case updateTitle(String) | ||
|
||
/// Action to dismiss an error | ||
case dismissError | ||
|
||
/// Action to submit the updated working copy banner to the network | ||
case submit | ||
} | ||
|
||
@Published var state: State | ||
var publishedState: AnyPublisher<State, Never> { | ||
return $state.eraseToAnyPublisher() | ||
} | ||
|
||
// MARK: - BannerUpdateViewStore | ||
|
||
private let bannerDataStore: any BannerDataStoreType | ||
|
||
private let newTitlePublisher = PassthroughSubject<String, Never>() | ||
|
||
/// Creates a new `BannerUpdateViewStore`. | ||
/// - Parameter bannerDataStore: The data `Store` responsible for updating the banner on the network and its source of truth in the application. | ||
init(bannerDataStore: any BannerDataStoreType) { | ||
self.bannerDataStore = bannerDataStore | ||
|
||
state = State(bannerViewState: bannerDataStore.state, workingCopy: bannerDataStore.state.banner) | ||
|
||
bannerDataStore | ||
.publishedState | ||
.combineLatest(newTitlePublisher.map(Banner.init).prepend(state.workingCopy)) | ||
.map { bannerState, workingCopy in | ||
State(bannerViewState: bannerState, workingCopy: workingCopy) | ||
} | ||
.assign(to: &$state) | ||
} | ||
|
||
// MARK: - Store | ||
|
||
func send(_ action: Action) { | ||
switch action { | ||
case .updateTitle(let title): | ||
newTitlePublisher.send(title) | ||
case .submit: | ||
bannerDataStore.send(.uploadBanner(state.workingCopy)) | ||
case .dismissError: | ||
bannerDataStore.send(.clearNetworkingState) | ||
} | ||
} | ||
|
||
} | ||
|
||
extension BannerUpdateViewStoreType { | ||
/// Computed property that creates a binding for the working title | ||
var workingTitle: Binding<String> { | ||
makeBinding(stateKeyPath: \.workingCopy.title, actionCasePath: /Action.updateTitle) | ||
} | ||
|
||
/// Computed property that creates a binding for the error presentation state | ||
var isErrorPresented: Binding<Bool> { | ||
.init(get: { | ||
return self.state.error != nil | ||
}, set: { _ in | ||
self.send(.dismissError) | ||
}) | ||
} | ||
} |
Oops, something went wrong.