Skip to content

Commit

Permalink
Auto-Rendering and State Behavior Documentation (#34)
Browse files Browse the repository at this point in the history
* Simplified state declaration example

* RenderedViewState will/did set documentation

* willSetPublisher SwiftUI documentation

* Added startRendering example to demo app

* willSetPublisher SwiftUI documentation

* RenderedViewState will/did set documentation

* UIKit auto-rendering documentation update

* Removed UIKit documentation anti-pattern

* Removed publisher anti-pattern documentation

* Fixed example repository type definition

* Fix markdown lint

* Fix markdown lint
  • Loading branch information
albertbori authored Mar 3, 2023
1 parent aba0c5a commit 3c2cbb3
Show file tree
Hide file tree
Showing 5 changed files with 120 additions and 27 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
6 changes: 1 addition & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<UserDataState, Never>(.loading)
var userDataPublisher: AnyPublisher<UserDataState, Never> {
userDataSubject.share().eraseToAnyPublisher()
}
lazy var userDataPublisher: AnyPublisher<UserDataState, Never> = {
userDataSubject.eraseToAnyPublisher()
}()

func load() -> AnyPublisher<UserData, Error> {
...
Expand All @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>` 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<T>` 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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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 <doc:ViewDefinition-SwiftUI> 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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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<AnyCancellable> = []
...
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.
Expand Down

0 comments on commit 3c2cbb3

Please sign in to comment.