Skip to content

Commit

Permalink
VSM Property Wrappers Documentation (#27)
Browse files Browse the repository at this point in the history
* property wrapper PoC (AutoRendered + ViewState)

* Deprecate ViewStateRendering for the ViewState property wrapper

* Fixed observe state wrapper issues

* Split view state property wrappers

* Clarified deprecation warnings

* Upgraded debug logging

* Migrate demo views to ViewState property wrapper

* Missed container removal

* Fixed typo in ViewStateRendering docs

* Updated documentation to recommend @ViewState

* Code file organization

* Fixed extra-frame bug

* Added a unit test to ensure synchronous main-thread action observation

* Changed ViewState wrapper from struct to protocol

* Fixed runtime StateObject access warnings

* Added UI tests to UIKit scheme

* Organized conformances, docs, and fixed async disambiguation bug

* Clarified state management property names

* Added profile view and tests for debounce field and async actions

* Add storyboard view controller and tests for second RenderedViewState PoC

* Undo merge fragments

* Undo whitespace change

* Undo unnecessary ViewState rename

* Removed redundant deprecation decorators

* File cleanup

* Update static debug logging signatures

* Binding protocol cleanup and async closure fix

* Consolidated State Container code documentation

* Update View Definition docs

* Property wrapper docs cleanup

* Documentation PR feedback

* markdown lint fix

* Documentation PR feedback

* Apply suggestions from code review

Co-authored-by: Mark Granoff <[email protected]>

---------

Co-authored-by: Mark Granoff <[email protected]>
  • Loading branch information
albertbori and granoff authored Feb 2, 2023
1 parent 43f5975 commit 41113cb
Show file tree
Hide file tree
Showing 30 changed files with 346 additions and 331 deletions.
30 changes: 20 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,13 @@ The following are code excerpts of a feature that shows a blog entry from a data

### State Definition

The state is usually defined as an enum or a struct and represents the states that the view can have. It also declares the data and actions available for each model. Actions return one or more new states.
The state is usually defined as an enum or a struct and represents the states that the view can have. It also declares the data and actions available to the view for each model. Actions return one or more new states.

```swift
enum BlogEntryViewState {
case initialized(loaderModel: LoaderModeling)
case loading(errorModel: ErrorModeling?)
case loaded(blogModel: LoadedModeling)
case loaded(blogModel: BlogModeling)
}

protocol LoaderModeling {
Expand All @@ -45,15 +45,16 @@ protocol ErrorModeling {
func retry() -> AnyPublisher<BlogArticleViewState, Never>
}

protocol LoadedModeling {
protocol BlogModeling {
var title: String { get }
var text: String { get }
func refresh() -> AnyPublisher<BlogArticleViewState, Never>
}
```

### Model Definition

The models provide the data for a given view state and implement the business logic.
The discrete models provide the data for a given view state and implement the business logic within the actions.

```swift
struct LoaderModel: LoaderModeling {
Expand All @@ -69,32 +70,41 @@ struct ErrorModel: ErrorModeling {
}
}

struct LoadedModel: LoadedModeling {
struct BlogModel: BlogModeling {
var title: String
var body: String
func refresh() -> AnyPublisher<BlogArticleViewState, Never> {
...
}
}
```

### View Definition

The view observes and renders the state using the `StateContainer` type. State changes will automatically update the view.
The view observes and renders the state using the `ViewState` property wrapper. State changes will automatically update the view.

```swift
struct BlogEntryView: View, ViewStateRendering {
@StateObject var container: StateContainer<BlogEntryViewState>
struct BlogEntryView: View {
@ViewState var state: BlogEntryViewState

init() {
_container = .init(state: .initialized(LoaderModel()))
_state = .init(wrappedValue: .initialized(LoaderModel()))
}

var body: some View {
switch state {
case .initialized(loaderModel: let loaderModel):
...
.onAppear {
$state.observe(loaderModel.load())
}
case .loading(errorModel: let errorModel):
...
case .loaded(loadedModel: let loadedModel)
case .loaded(blogModel: let blogModel)
...
Button("Reload") {
$state.observe(blogModel.refresh())
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -193,13 +193,13 @@ typealias Dependencies = UserDataProvidingDependency
The resulting initializer chain will end up looking something like this:

```swift
struct UserBioView: View, ViewStateRendering {
struct UserBioView: View {
typealias Dependencies = UserBioViewState.LoaderModel.Dependencies
& UserBioViewState.ErrorModel.Dependencies
init(dependencies: Dependencies) {
let loaderModel = UserBioViewState.LoaderModel(dependencies: Dependencies)
let state = UserBioViewState.initialized(loaderModel)
_container = .init(state: state)
_state = .init(wrappedValue: state)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ States usually match up 1:1 with variations in the view. So, we can safely assum
> ```swift
> someView.onAppear {
> if case .initialized(let loadingModel) = state {
> observe(loadingModel.load())
> $state.observe(loadingModel.load())
> }
> }
> ```
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ In VSM, the various responsibilities of a feature are divided into 3 concepts:

The structure of your code should follow the above pattern, with a view code file, a view state code file, and a file for each model's implementation.

Optionally, due to the reactive nature of VSM, Observable Repositories are an excellent companion to VSM models in performing data operations (such as loading, saving, etc.) whose results can be forwarded to the view. These repositories can be shared between views for a powerful, yet safe approach to synchronizing the state of various views and data in the app.
Thanks to the reactive nature of VSM, Observable Repositories are an excellent companion to VSM models in performing data operations (such as loading, saving, etc.) and managing the state of data. This data can then be forwarded to the view via Combine Publishers.

These repositories can also be shared between views to synchronize the state of various views and data in the app. While simple features may not need these repositories, they are an excellent tool for complex features. You'll learn more about these later in the guide.

![VSM Feature Structure Diagram](vsm-structure.jpg)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,28 +15,24 @@ The basic structure of a SwiftUI VSM view is as follows:
```swift
import VSM

struct LoadUserProfileView: View, ViewStateRendering {
@StateObject var container: StateContainer<LoadUserProfileViewState>
struct LoadUserProfileView: View {
@ViewState var state: LoadUserProfileViewState

var body: some View {
// View definitions go here
}
}
```

We are required by the ``ViewStateRendering`` protocol to define a ``StateContainer`` property and specify what the view state's type will be. In these examples, we will use the `LoadUserProfileViewState` and `EditUserProfileViewState` types from <doc:StateDefinition> to build two related VSM views.
To turn any view into a "VSM View", define a property that holds our current state and decorate it with the ``ViewState`` (`@ViewState`) property wrapper.

In SwiftUI, the `view` property is evaluated and the view is redrawn _every time the state changes_. In addition, any time a dynamic property changes, the `view` property will be reevaluated and redrawn. This includes properties wrapped with `@StateObject`, `@State`, `@ObservedObject`, and `@Binding`.
**The `@ViewState` property wrapper updates the view every time the state changes**. It works in the same way as other SwiftUI property wrappers (i.e., `@StateObject`, `@State`, `@ObservedObject`, and `@Binding`).

> Note: In SwiftUI, a view's initializer is called every time its parent view is updated and redrawn.
>
> The `@StateObject` property wrapper is the safest choice for declaring your `StateContainer` property. A `StateObject`'s current value is maintained by SwiftUI between redraws of the parent view. In contrast, `@ObservedObject`'s value is not maintained between redraws of the parent view, so it should only be used in scenarios where the view state can be safely recovered every time the parent view is redrawn.
## Displaying the State
As with other SwiftUI property wrappers, when the wrapped value (state) changes, the view's `body` property is reevaluated and the result is drawn on the screen.

The ``ViewStateRendering`` protocol provides a few properties and functions that help with displaying the current state, accessing the state data, and invoking actions.
In the following examples, we will use the `LoadUserProfileViewState` and `EditUserProfileViewState` types from <doc:StateDefinition> to build two related VSM views.

The first of these members is the ``ViewStateRendering/state`` property, which is always set to the current state.
## Displaying the State

As a refresher, the following flow chart expresses the requirements that we wish to draw in the view.

Expand Down Expand Up @@ -64,11 +60,11 @@ protocol LoadingErrorModeling {
}
```

In SwiftUI, we simply write a switch statement within the `view` property to evaluate the current state and return the most appropriate view(s) for it.
In SwiftUI, we write a switch statement within the `body` property to evaluate the current state and draw the most appropriate content for it.

Note that if you avoid using a `default` case in your switch statement, the compiler will enforce any future changes to the shape of your feature. This is good because it will help you avoid bugs when maintaining the feature.

The resulting `view` property implementation takes this shape:
The resulting `body` property implementation takes this shape:

```swift
var body: some View {
Expand Down Expand Up @@ -117,17 +113,17 @@ protocol SavingErrorModeling {
}
```

To render this editing form, we require an extra property be added to the SwiftUI view to keep track of what the user types for the "Username" field.
To render this editing form, we need a property that keeps track of what the user types for the "Username" field. A `@State` property called "username" will do nicely.

```swift
struct EditUserProfileView: View, ViewStateRendering {
@StateObject var container: StateContainer<EditUserProfileViewState>
struct EditUserProfileView: View {
@ViewState var state: EditUserProfileViewState
@State var username: String = ""

init(userData: UserData) {
let editingModel = EditUserProfileViewState.EditingModel(userData: userData)
let state = EditUserProfileViewState(data: userData, editingState: .editing(editingModel))
_container = .init(state: state)
_state = .init(wrappedValue: state)
}

var body: some View {
Expand Down Expand Up @@ -166,7 +162,7 @@ struct EditUserProfileView: View, ViewStateRendering {
}
```

Since the root type of this view state is a struct instead of an enum, and this view has a more complicated hierarchy, you'll notice that we don't use a switch statement. Instead, we place components where they need to go and sprinkle in logic within areas of the view, as necessary.
Since the root type of this view state is a `struct` instead of an `enum`, and this view has a more complicated hierarchy, you'll notice that we don't use a switch statement. Instead, we place components where they need to go and sprinkle in logic within areas of the view, as necessary.

Additionally, you'll notice that there is a reference to a previously unknown view state member in the property wrapper `.disabled(state.isSaving)`. Due to the programming style used in SwiftUI APIs, we sometimes have to extend our view state to transform its shape to work better with SwiftUI views. We define these in view state extensions so that we can preserve the type safety of our feature shape, while reducing the friction when working with specific view APIs.

Expand Down Expand Up @@ -204,17 +200,15 @@ extension EditUserProfileViewState {
Now that we have our view states rendering correctly, we need to wire up the various actions in our views so that they are appropriately and safely invoked by the environment or the user.
VSM's ``ViewStateRendering`` protocol provides a critically important function called ``ViewStateRendering/observe(_:)-7vht3``. This function updates the current state with all view states emitted by the action parameter, as they are emitted in real-time.
VSM's ``ViewState`` property wrapper provides a critically important function called ``StateObserving/observe(_:)-31ocs`` through its projected value (`$`). This function updates the current state with all view states emitted by an action, as they are emitted in real-time.
It is called like so:
```swift
observe(someState.someAction())
// or
observe(someState.someAction)
$state.observe(someState.someAction())
```
The only way to update the current view state is to use the `observe(_:)` function.
The only way to update the current view state is to use the `ViewState`'s `observe(_:)` function.

When `observe(_:)` is called, it cancels any existing Combine publisher subscriptions or Swift Concurrency tasks and ignores view state updates from any previously called actions. This prevents future view state corruption from previous actions and frees up device resources.

Expand All @@ -228,7 +222,7 @@ This is a helpful reminder in case you forget to wrap an action call with `obser
### Loading View Actions

There are two actions that we want to configure in the `LoadUserProfileView`. The `load()` action in the `initialized` view state and the `retry()` action for the `loadingError` view state. We want `load()` to be called only once in the view's lifetime, so we'll attach it to the `onAppear` event handler on one of the subviews. The `retry()` action will be nestled in the view that uses the unwrapped `errorModel`.
There are two actions that we want to call in the `LoadUserProfileView`. The `load()` action in the `initialized` view state and the `retry()` action for the `loadingError` view state. We want `load()` to be called only once in the view's lifetime, so we'll attach it to the `onAppear` event handler on one of the subviews. The `retry()` action will be nestled in the view that uses the unwrapped `errorModel`.

```swift
var body: some View {
Expand All @@ -241,13 +235,13 @@ var body: some View {
case .loadingError(let errorModel):
Text(errorModel.message)
Button("Retry") {
observe(errorModel.retry())
$state.observe(errorModel.retry())
}
}
}
.onAppear {
if case .initialized(let loaderModel) = state {
observe(loaderModel.load())
$state.observe(loaderModel.load())
}
}
}
Expand All @@ -271,7 +265,7 @@ var body: some View {
.textFieldStyle(.roundedBorder)
Button("Save") {
if case .editing(let editingModel) = state.editingState {
observe(editingModel.save(username: username))
$state.observe(editingModel.save(username: username))
}
}
}
Expand All @@ -285,10 +279,10 @@ var body: some View {
Text(errorModel.message)
HStack {
Button("Retry") {
observe(errorModel.retry())
$state.observe(errorModel.retry())
}
Button("Cancel") {
observe(errorModel.cancel())
$state.observe(errorModel.cancel())
}
}
}
Expand Down Expand Up @@ -343,27 +337,27 @@ var body: some View {
ZStack {
...
}
.onReceive(container.$state) { newState in
.onReceive($state.publisher) { newState in
username = newState.data.username
}
}
```

We have to use the `ViewStateRendering`'s ``ViewStateRendering/container`` property because it gives us access to the underlying `StateContainer`'s observable `@Published` ``StateContainer/state`` property which can be observed by `onReceive`.
We use the `ViewState`'s projected value (`$`) because it gives us access to the state ``StatePublishing/publisher`` property which can be observed by `onReceive`.

#### Custom Two-Way Bindings

If we wanted to ditch the `Save` button in favor of having the view input call `save(username:)` as the user is typing, SwiftUI's `Binding<T>` type behaves much like a property on an object by providing a two-way getter and a setter for a wrapped value. We can utilize this to trick the `TextField` view into thinking it has read/write access to the view state's `username` property.

A custom `Binding<T>` can be created as a view state extension property, as a `@Binding` property on the view, or on the fly right within the view's code, like so:
A custom `Binding<T>` can be created as a view state extension property, as a `@Binding` property on the view, or right within the view's code, like so:

```swift
var body: some View {
let usernameBinding = Binding(
get: { state.data.username },
set: { newValue in
if case .editing(let editingModel) = state.editingState {
observe(editingModel.save(username: newValue),
$state.observe(editingModel.save(username: newValue),
debounced: .seconds(1))
}
}
Expand All @@ -373,7 +367,7 @@ var body: some View {
}
```

Notice how our call to ``ViewStateRendering/observe(_:debounced:file:line:)-7ihyy`` includes a `debounced` property. This allows us to prevent thrashing the `save(username:)` call if the user is typing quickly. It will only call the action a maximum of once per second (or whatever time delay is given).
Notice how our call to ``StateObserving/observe(_:debounced:file:line:)-8vbf2`` includes a `debounced` parameter. This prevents excessive calls to the `save(username:)` function if the user is typing quickly. It will only call the action a maximum of once per second (or whatever time delay is given).

## View Construction

Expand All @@ -384,42 +378,60 @@ A VSM view's initializer can take either of two approaches (or both, if desired)
- Dependent: The parent is responsible for passing in the view's initial view state (and its associated model)
- Encapsulated: The view encapsulates its view state kickoff point (and associated model), only requiring that the parent provide dependencies needed by the view or the models.

The dependent initializer has one upside and one downside when compared to the encapsulated approach. The upside is that the initializer is convenient for use in SwiftUI Previews and automated UI tests. The downside is that it requires any parent view to have some knowledge of the inner workings of the view in question.
The "Dependent" initializer has two upsides and one downside when compared to the encapsulated approach. The upsides are that Swift provides a default initializer automatically and the initializer is convenient for use in SwiftUI Previews and automated UI tests. The downside is that it requires parent views to have some knowledge of the inner workings of the view in question.

### Loading View Initializers

The initializers for the `LoadUserProfileView` are as follows:

"Dependent" Approach

```swift
// Dependent
init(state: LoadUserProfileViewState) {
_container = .init(state: state)
}
// Parent View Code
let loaderModel = LoadUserProfileViewState.LoaderModel(userId: userId)
let state = .initialized(loaderModel)
LoadUserProfileView(state: state)
```

"Encapsulated" Approach

// Encapsulated
```swift
// LoadUserProfileView Code
init(userId: Int) {
let loaderModel = LoadUserProfileViewState.LoaderModel(userId: userId)
let state = .initialized(loaderModel)
_container = .init(state: state)
_state = .init(wrappedValue: state)
}

// Parent View Code
LoadUserProfileView(userId: someUserId)
```

### Editing View Initializers

The initializers for the `EditUserProfileView` are as follows:

"Dependent" Approach

```swift
// Dependent
init(state: EditUserProfileViewState) {
_container = .init(state: state)
}
// Parent View Code
let editingModel = EditUserProfileViewState.EditingModel(userData: userData)
let state = EditUserProfileViewState(data: userData, editingState: .editing(editingModel))
EditUserProfileView(state: state)
```

"Encapsulated" Approach

// Encapsulated
```swift
// EditUserProfileView Code
init(userData: UserData) {
let editingModel = EditUserProfileViewState.EditingModel(userData: userData)
let state = EditUserProfileViewState(data: userData, editingState: .editing(editingModel))
_container = .init(state: state)
_state = .init(wrappedValue: state)
}

// Parent View Code
EditUserProfileView(userData: someUserData)
```

## Iterative View Development
Expand Down
Loading

0 comments on commit 41113cb

Please sign in to comment.