diff --git a/Demos/Shopping/Shopping/Views/Product/ProductDetailView/ProductDetailViewController.swift b/Demos/Shopping/Shopping/Views/Product/ProductDetailView/ProductDetailViewController.swift index 855a674..af9be6c 100644 --- a/Demos/Shopping/Shopping/Views/Product/ProductDetailView/ProductDetailViewController.swift +++ b/Demos/Shopping/Shopping/Views/Product/ProductDetailView/ProductDetailViewController.swift @@ -76,8 +76,7 @@ class ProductDetailViewController: UIViewController { loadProductImage(from: productDetail.imageURL) productImage.accessibilityIdentifier = "\(productDetail.name) Image" productDetailLabel.text = productDetail.description - confirmationView.isHidden = true - errorView.isHidden = true + $state.startRendering(on: self) } func render(newState: ProductDetailViewState) { diff --git a/README.md b/README.md index c14c534..6906b68 100644 --- a/README.md +++ b/README.md @@ -85,11 +85,7 @@ The view observes and renders the state using the `ViewState` property wrapper. ```swift struct BlogEntryView: View { - @ViewState var state: BlogEntryViewState - - init() { - _state = .init(wrappedValue: .initialized(LoaderModel())) - } + @ViewState var state: BlogEntryViewState = .initialized(LoaderModel()) var body: some View { switch state { diff --git a/Sources/VSM/Documentation.docc/ComprehensiveGuide/DataDefinition.md b/Sources/VSM/Documentation.docc/ComprehensiveGuide/DataDefinition.md index 74f93e3..c539d37 100644 --- a/Sources/VSM/Documentation.docc/ComprehensiveGuide/DataDefinition.md +++ b/Sources/VSM/Documentation.docc/ComprehensiveGuide/DataDefinition.md @@ -36,11 +36,11 @@ The publishers returned from the functions let features react to the individual The basic implementation for the repository may look something like this: ```swift -struct UserDataRepository: UserDataProviding { +class UserDataRepository: UserDataProviding { private var userDataSubject = CurrentValueSubject(.loading) - var userDataPublisher: AnyPublisher { - userDataSubject.share().eraseToAnyPublisher() - } + lazy var userDataPublisher: AnyPublisher = { + userDataSubject.eraseToAnyPublisher() + }() func load() -> AnyPublisher { ... @@ -54,7 +54,7 @@ struct UserDataRepository: UserDataProviding { We choose to manage the user data by way of the `CurrentValueSubject` publisher which always emits the current value to new subscribers and will emit any future changes to the subject's value property (or `.send(_:)` function). We also make sure to set the error type to `Never` because this specific publisher is only meant to keep track of the most recent stable value. -We expose the current value by using a type-erased publisher property, as dictated by the `UserDataProviding` protocol. We make sure to `share()` this publisher so that all subscribers receive the same state updates. +We expose the current value by using a type-erased publisher property, as dictated by the `UserDataProviding` protocol. Now, how do we keep this shared value up to date? As we implement the actions that manipulate the data, as you would expect from any repository, we'll make sure those actions appropriately update the state of the data. diff --git a/Sources/VSM/Documentation.docc/ComprehensiveGuide/ViewDefinition-SwiftUI.md b/Sources/VSM/Documentation.docc/ComprehensiveGuide/ViewDefinition-SwiftUI.md index e670068..ee597a9 100644 --- a/Sources/VSM/Documentation.docc/ComprehensiveGuide/ViewDefinition-SwiftUI.md +++ b/Sources/VSM/Documentation.docc/ComprehensiveGuide/ViewDefinition-SwiftUI.md @@ -303,7 +303,41 @@ All business logic belongs in VSM models and associated repositories. However, t - Receiving/streaming user input - Animating the view -You will most often see these types of data expressed as properties on a SwiftUI view with the `@State` or `@Binding` property wrappers. There are a handful of approaches in which VSM can synchronize between these view properties and the current view state. The two most common approaches are by using custom `Binding` objects, or by imperatively manipulating view properties and calling VSM actions via the view event handlers. +You will most often see these types of data expressed as properties on a SwiftUI view with the `@State` or `@Binding` property wrappers. There are a handful of approaches in which VSM can synchronize between these view properties and the current view state. The two most common approaches are by using custom `Binding` objects, or by manipulating view properties and calling VSM actions via the view event handlers. + +### Comparing State Changes + +VSM provides additional tools for assisting in some of this view-centric logic for SwiftUI views. One such tool is ``RenderedViewState/RenderedContainer/willSetPublisher``. This publisher enables SwiftUI view properties to be modified in a performant way when the state changes. It also enables engineers to compare the current and future view states. + +The following example displays a progress view that shows the loading state of some imaginary data operation. It begins loading when the view first appears and then animates the progress bar as the bytes are loaded. The view utilizes an `@State` property for animating the progress view and keeps the value up to date by observing the view state's `willSetPublisher`. + +```swift +struct MyView: View { + @ViewState var state: MyViewState + @State var progress: Double = 0 + + var body: some View { + ProgressView("Loading...", value: progress) + .onAppear { + if case .initialized(let loaderModel) = state { + $state.observe(loaderModel.load()) + } + } + .onReceive($state.willSetPublisher) { newState in + switch (state, newState) { + case (.loading(let oldLoadingModel), .loading(let newLoadingModel)): + guard oldLoadingModel.loadedBytes < newLoadingModel.loadedBytes else { return } + print(">>> Animating progress from \(oldLoadingModel.loadedBytes) to \(newLoadingModel.loadedBytes) bytes") + withAnimation() { + progress = newLoadingModel.loadedBytes / newLoadingModel.totalBytes + } + default: + break + } + } + } +} +``` ### Logic Coordination for the Editing View diff --git a/Sources/VSM/Documentation.docc/ComprehensiveGuide/ViewDefinition-UIKit.md b/Sources/VSM/Documentation.docc/ComprehensiveGuide/ViewDefinition-UIKit.md index 532f55c..e45e41a 100644 --- a/Sources/VSM/Documentation.docc/ComprehensiveGuide/ViewDefinition-UIKit.md +++ b/Sources/VSM/Documentation.docc/ComprehensiveGuide/ViewDefinition-UIKit.md @@ -8,6 +8,8 @@ VSM is a reactive architecture and as such is a natural fit for SwiftUI, but it The purpose of the "View" in VSM is to render the current view state and provide the user access to the data and actions available in that state. +In the examples found in this article, we will be using Storyboards. The code-first approach to UIKit can also be used by changing how you initialize your UIView or UIViewController. + ## View Structure The basic structure of a UIKit VSM view is as follows: @@ -31,11 +33,41 @@ class UserProfileViewController: UIViewController { } ``` -To turn any UIView or UIViewController into a "VSM View", define a property that holds our current state and decorate it with the `@RenderedViewState` property wrapper. +To turn any UIView or UIViewController into a "VSM View", define a property that holds our current state and decorate it with the `@RenderedViewState` property wrapper. `@RenderViewState` is designed for UIKit and will not work in SwiftUI. (See for more information.) + +**The `@RenderedViewState` property wrapper updates the view every time the state changes**. `@RenderedViewState` requires a `render` _function type_ parameter to call when the state changes. You must define this function in your UIView or UIViewController. + +To kick off this automatic rendering, you must choose an appropriate UIView or UIViewController lifecycle event (`viewDidLoad`, `viewWillAppear`, etc.) and apply one of these two approaches: + +### Auto-Render: Option A + +Automatic rendering will begin simply by accessing the `state` property. In VSM, it is common to begin your view's state journey by observing an action early in the view's lifecycle. + +Example + +```swift +func viewDidLoad() { + super.viewDidLoad() + if case .initialized(let loaderModel) = state { + $state.observe(loaderModel.load()) + } +} +``` + +### Auto-Render: Option B -**The UIKit-only `@RenderedViewState` property wrapper updates the view every time the state changes**. `@RenderedViewState` requires a `render` _function type_ parameter to call when the state changes. You must define this function in your UIView or UIViewController. +Call `$state.startRendering(on: self)` at any point after initialization. This won't progress your state, but it will cause the automatic rendering to begin. This is most commonly used when the view's state journey is begun by some user action (e.g. tapping a button) and not a view lifecycle event. -> Note: In the examples found in this article, we will be using Storyboards. As a result, we used a custom `NSCoder` initializer. If you are using a code-first approach to UIKit, you can use whichever initialization mechanism is most appropriate. +Example + +```swift +func viewDidLoad() { + super.viewDidLoad() + $state.startRendering(on: self) +} +``` + +> Warning: If you fail to implement one of the above auto-render approaches, the `render` function will never be called and the view state will be inert. ## Displaying the State @@ -126,17 +158,6 @@ The `loadingError` case shows the error view on top of all of the content and se The `loaded` state, however, does build and configure a new view because it will only ever be called once and it needs to pass data into the editing view which requires `UserData` for initialization. The loaded state also stops and hides the loading indicator and the error view (if previously shown). -> Note: If a new view _must_ be repeatedly rebuilt due to state changes, be sure to properly clear the previous views, like so: - -```swift -contentView.subviews.forEach { $0.removeFromSuperview() } -children.forEach { child in - child.willMove(toParent: nil) - child.removeFromParent() - child.didMove(toParent: nil) -} -``` - ### Editing View If we go back up to the feature's flow chart and translate the editing behavior (the right section of the state machine) to a view state, we come up with the following view state: @@ -393,6 +414,49 @@ All business logic belongs in VSM models and associated repositories. However, t - Receiving/streaming user input - Animating the view +### Comparing State Changes + +VSM provides additional tools for assisting in some of this view-centric logic for UIKit views. One such tool is the ability to compare the current view state against the future view state when rendering. To do this, simply add a view state parameter to the `render(...)` function. By adding a view state property to the render function, VSM will call the render function on the `state` property's `willSet` event instead of the `didSet` event. + +Example + +```swift +func render(_ newState: MyViewState) { + if state.saveProgress < newState.saveProgress) { + animateSaveProgress(from: state.saveProgress, to: newState.saveProgress) + } +} +``` + +In the above example, the `state` view property still contains the previous view state value, while the parameter passed into the `render(_ newState: MyViewState)` function contains the new view state _just before the `state` property is changed to the new value_. This allows you to perform any logic or operations that require a comparison of the current and future states. + +### Will-Set / Did-Set Publishers + +The ``RenderedViewState/RenderedContainer/willSetPublisher`` and ``RenderedViewState/RenderedContainer/didSetPublisher`` publishers provide another tool for supporting view-centric logic. These publishers can be used to observe and respond to changes in view state as desired. These publishers are guaranteed to send the new value on the main thread. + +Example + +```swift +class MyViewController: UIViewController { + @RenderedViewState var state: MyViewState + private var stateSubscriptions: Set = [] + ... + override func viewDidLoad() { + super.viewDidLoad() + $state.willSetPublisher + .sink { newState in + print(">>> will set: \(newState)" + } + .store(in: &stateSubscriptions) + $state.didSetPublisher + .sink { newState in + print(">>> did set: \(newState)" + } + .store(in: &stateSubscriptions) + } +} +``` + ## Iterative View Development The best approach to building features in VSM is to start with defining the view state, then move straight to building the view. Rely on mocked states and example/demo apps where possible to visualize each state. Postpone implementing the feature's business logic for as long as possible until you are confident that you have the right feature shape and view code.