Skip to content

Commit 41113cb

Browse files
albertborigranoff
andauthored
VSM Property Wrappers Documentation (#27)
* 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 <mark@granoff.net> --------- Co-authored-by: Mark Granoff <mark@granoff.net>
1 parent 43f5975 commit 41113cb

30 files changed

+346
-331
lines changed

README.md

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,13 @@ The following are code excerpts of a feature that shows a blog entry from a data
2727

2828
### State Definition
2929

30-
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.
30+
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.
3131

3232
```swift
3333
enum BlogEntryViewState {
3434
case initialized(loaderModel: LoaderModeling)
3535
case loading(errorModel: ErrorModeling?)
36-
case loaded(blogModel: LoadedModeling)
36+
case loaded(blogModel: BlogModeling)
3737
}
3838

3939
protocol LoaderModeling {
@@ -45,15 +45,16 @@ protocol ErrorModeling {
4545
func retry() -> AnyPublisher<BlogArticleViewState, Never>
4646
}
4747

48-
protocol LoadedModeling {
48+
protocol BlogModeling {
4949
var title: String { get }
5050
var text: String { get }
51+
func refresh() -> AnyPublisher<BlogArticleViewState, Never>
5152
}
5253
```
5354

5455
### Model Definition
5556

56-
The models provide the data for a given view state and implement the business logic.
57+
The discrete models provide the data for a given view state and implement the business logic within the actions.
5758

5859
```swift
5960
struct LoaderModel: LoaderModeling {
@@ -69,32 +70,41 @@ struct ErrorModel: ErrorModeling {
6970
}
7071
}
7172

72-
struct LoadedModel: LoadedModeling {
73+
struct BlogModel: BlogModeling {
7374
var title: String
7475
var body: String
76+
func refresh() -> AnyPublisher<BlogArticleViewState, Never> {
77+
...
78+
}
7579
}
7680
```
7781

7882
### View Definition
7983

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

8286
```swift
83-
struct BlogEntryView: View, ViewStateRendering {
84-
@StateObject var container: StateContainer<BlogEntryViewState>
87+
struct BlogEntryView: View {
88+
@ViewState var state: BlogEntryViewState
8589

8690
init() {
87-
_container = .init(state: .initialized(LoaderModel()))
91+
_state = .init(wrappedValue: .initialized(LoaderModel()))
8892
}
8993

9094
var body: some View {
9195
switch state {
9296
case .initialized(loaderModel: let loaderModel):
9397
...
98+
.onAppear {
99+
$state.observe(loaderModel.load())
100+
}
94101
case .loading(errorModel: let errorModel):
95102
...
96-
case .loaded(loadedModel: let loadedModel)
103+
case .loaded(blogModel: let blogModel)
97104
...
105+
Button("Reload") {
106+
$state.observe(blogModel.refresh())
107+
}
98108
}
99109
}
100110
}

Sources/VSM/Documentation.docc/ComprehensiveGuide/DataDefinition.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -193,13 +193,13 @@ typealias Dependencies = UserDataProvidingDependency
193193
The resulting initializer chain will end up looking something like this:
194194

195195
```swift
196-
struct UserBioView: View, ViewStateRendering {
196+
struct UserBioView: View {
197197
typealias Dependencies = UserBioViewState.LoaderModel.Dependencies
198198
& UserBioViewState.ErrorModel.Dependencies
199199
init(dependencies: Dependencies) {
200200
let loaderModel = UserBioViewState.LoaderModel(dependencies: Dependencies)
201201
let state = UserBioViewState.initialized(loaderModel)
202-
_container = .init(state: state)
202+
_state = .init(wrappedValue: state)
203203
}
204204
}
205205

Sources/VSM/Documentation.docc/ComprehensiveGuide/StateDefinition.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ States usually match up 1:1 with variations in the view. So, we can safely assum
120120
> ```swift
121121
> someView.onAppear {
122122
> if case .initialized(let loadingModel) = state {
123-
> observe(loadingModel.load())
123+
> $state.observe(loadingModel.load())
124124
> }
125125
> }
126126
> ```

Sources/VSM/Documentation.docc/ComprehensiveGuide/VSMOverview.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ In VSM, the various responsibilities of a feature are divided into 3 concepts:
1616

1717
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.
1818

19-
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.
19+
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.
20+
21+
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.
2022

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

Sources/VSM/Documentation.docc/ComprehensiveGuide/ViewDefinition-SwiftUI.md

Lines changed: 59 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -15,28 +15,24 @@ The basic structure of a SwiftUI VSM view is as follows:
1515
```swift
1616
import VSM
1717

18-
struct LoadUserProfileView: View, ViewStateRendering {
19-
@StateObject var container: StateContainer<LoadUserProfileViewState>
18+
struct LoadUserProfileView: View {
19+
@ViewState var state: LoadUserProfileViewState
2020

2121
var body: some View {
2222
// View definitions go here
2323
}
2424
}
2525
```
2626

27-
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.
27+
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.
2828

29-
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`.
29+
**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`).
3030

31-
> Note: In SwiftUI, a view's initializer is called every time its parent view is updated and redrawn.
32-
>
33-
> 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.
34-
35-
## Displaying the State
31+
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.
3632

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

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

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

@@ -64,11 +60,11 @@ protocol LoadingErrorModeling {
6460
}
6561
```
6662

67-
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.
63+
In SwiftUI, we write a switch statement within the `body` property to evaluate the current state and draw the most appropriate content for it.
6864

6965
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.
7066

71-
The resulting `view` property implementation takes this shape:
67+
The resulting `body` property implementation takes this shape:
7268

7369
```swift
7470
var body: some View {
@@ -117,17 +113,17 @@ protocol SavingErrorModeling {
117113
}
118114
```
119115

120-
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.
116+
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.
121117

122118
```swift
123-
struct EditUserProfileView: View, ViewStateRendering {
124-
@StateObject var container: StateContainer<EditUserProfileViewState>
119+
struct EditUserProfileView: View {
120+
@ViewState var state: EditUserProfileViewState
125121
@State var username: String = ""
126122

127123
init(userData: UserData) {
128124
let editingModel = EditUserProfileViewState.EditingModel(userData: userData)
129125
let state = EditUserProfileViewState(data: userData, editingState: .editing(editingModel))
130-
_container = .init(state: state)
126+
_state = .init(wrappedValue: state)
131127
}
132128

133129
var body: some View {
@@ -166,7 +162,7 @@ struct EditUserProfileView: View, ViewStateRendering {
166162
}
167163
```
168164

169-
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.
165+
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.
170166

171167
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.
172168

@@ -204,17 +200,15 @@ extension EditUserProfileViewState {
204200
205201
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.
206202
207-
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.
203+
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.
208204
209205
It is called like so:
210206
211207
```swift
212-
observe(someState.someAction())
213-
// or
214-
observe(someState.someAction)
208+
$state.observe(someState.someAction())
215209
```
216210
217-
The only way to update the current view state is to use the `observe(_:)` function.
211+
The only way to update the current view state is to use the `ViewState`'s `observe(_:)` function.
218212

219213
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.
220214

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

231-
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`.
225+
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`.
232226

233227
```swift
234228
var body: some View {
@@ -241,13 +235,13 @@ var body: some View {
241235
case .loadingError(let errorModel):
242236
Text(errorModel.message)
243237
Button("Retry") {
244-
observe(errorModel.retry())
238+
$state.observe(errorModel.retry())
245239
}
246240
}
247241
}
248242
.onAppear {
249243
if case .initialized(let loaderModel) = state {
250-
observe(loaderModel.load())
244+
$state.observe(loaderModel.load())
251245
}
252246
}
253247
}
@@ -271,7 +265,7 @@ var body: some View {
271265
.textFieldStyle(.roundedBorder)
272266
Button("Save") {
273267
if case .editing(let editingModel) = state.editingState {
274-
observe(editingModel.save(username: username))
268+
$state.observe(editingModel.save(username: username))
275269
}
276270
}
277271
}
@@ -285,10 +279,10 @@ var body: some View {
285279
Text(errorModel.message)
286280
HStack {
287281
Button("Retry") {
288-
observe(errorModel.retry())
282+
$state.observe(errorModel.retry())
289283
}
290284
Button("Cancel") {
291-
observe(errorModel.cancel())
285+
$state.observe(errorModel.cancel())
292286
}
293287
}
294288
}
@@ -343,27 +337,27 @@ var body: some View {
343337
ZStack {
344338
...
345339
}
346-
.onReceive(container.$state) { newState in
340+
.onReceive($state.publisher) { newState in
347341
username = newState.data.username
348342
}
349343
}
350344
```
351345

352-
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`.
346+
We use the `ViewState`'s projected value (`$`) because it gives us access to the state ``StatePublishing/publisher`` property which can be observed by `onReceive`.
353347

354348
#### Custom Two-Way Bindings
355349

356350
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.
357351

358-
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:
352+
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:
359353

360354
```swift
361355
var body: some View {
362356
let usernameBinding = Binding(
363357
get: { state.data.username },
364358
set: { newValue in
365359
if case .editing(let editingModel) = state.editingState {
366-
observe(editingModel.save(username: newValue),
360+
$state.observe(editingModel.save(username: newValue),
367361
debounced: .seconds(1))
368362
}
369363
}
@@ -373,7 +367,7 @@ var body: some View {
373367
}
374368
```
375369

376-
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).
370+
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).
377371

378372
## View Construction
379373

@@ -384,42 +378,60 @@ A VSM view's initializer can take either of two approaches (or both, if desired)
384378
- Dependent: The parent is responsible for passing in the view's initial view state (and its associated model)
385379
- 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.
386380

387-
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.
381+
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.
388382

389383
### Loading View Initializers
390384

391385
The initializers for the `LoadUserProfileView` are as follows:
392386

387+
"Dependent" Approach
388+
393389
```swift
394-
// Dependent
395-
init(state: LoadUserProfileViewState) {
396-
_container = .init(state: state)
397-
}
390+
// Parent View Code
391+
let loaderModel = LoadUserProfileViewState.LoaderModel(userId: userId)
392+
let state = .initialized(loaderModel)
393+
LoadUserProfileView(state: state)
394+
```
395+
396+
"Encapsulated" Approach
398397

399-
// Encapsulated
398+
```swift
399+
// LoadUserProfileView Code
400400
init(userId: Int) {
401401
let loaderModel = LoadUserProfileViewState.LoaderModel(userId: userId)
402402
let state = .initialized(loaderModel)
403-
_container = .init(state: state)
403+
_state = .init(wrappedValue: state)
404404
}
405+
406+
// Parent View Code
407+
LoadUserProfileView(userId: someUserId)
405408
```
406409

407410
### Editing View Initializers
408411

409412
The initializers for the `EditUserProfileView` are as follows:
410413

414+
"Dependent" Approach
415+
411416
```swift
412-
// Dependent
413-
init(state: EditUserProfileViewState) {
414-
_container = .init(state: state)
415-
}
417+
// Parent View Code
418+
let editingModel = EditUserProfileViewState.EditingModel(userData: userData)
419+
let state = EditUserProfileViewState(data: userData, editingState: .editing(editingModel))
420+
EditUserProfileView(state: state)
421+
```
422+
423+
"Encapsulated" Approach
416424

417-
// Encapsulated
425+
```swift
426+
// EditUserProfileView Code
418427
init(userData: UserData) {
419428
let editingModel = EditUserProfileViewState.EditingModel(userData: userData)
420429
let state = EditUserProfileViewState(data: userData, editingState: .editing(editingModel))
421-
_container = .init(state: state)
430+
_state = .init(wrappedValue: state)
422431
}
432+
433+
// Parent View Code
434+
EditUserProfileView(userData: someUserData)
423435
```
424436

425437
## Iterative View Development

0 commit comments

Comments
 (0)