* updated binding docs
* adding docs
* clean up
* wip
* wip
* wip
* clean up
* clean up
* clean up
* wip;
* lots of fixes
* update more docs
* fix
* wip
* wip
* Remove ObservationRegistrarWrapper. (#2634)
* Remove ObservationRegistrarWrapper.
* Delete Sources/ComposableArchitecture/Internal/ObservationRegistrarWrapper.swift
---------
Co-authored-by: Stephen Celis <stephen@stephencelis.com>
* more docs
* update docs
* a few more tests
* fix
* wip
* wip
* wip
* Cache data in store collections (#2635)
* fix tutorial highlighting
* wip
* wip
* wip
* wip
* tests for observation of special domain types
* another test
* fix
* wip
* Implement memoization for perception checks (#2630)
* Implement memoization for isInSwiftUIBody
* tidy up
* Perception caching updates (#2649)
* Small updates to perception caching.
* wip
* debug
* some more macro tests
* syncups tutorial beginnings
* wip
* wip
* wip
* wip
* wip
* merge fixes
* wip
* update tests
* fix
* fix
* fix perception checking in store
* rename task local
* delete old test
* deprecate test using old apis
* fix test
* perception tests for store
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* Opt out of key path for Store.ifLet
* sync ups
* lots more sync up tutorial
* more sync ups tutorial
* wip
* wip
* wio
* wip
* wip
* wip
* updated references of 1.6 to 1.7
* wip
* no need to force unwrap here
* fixed crash in ForEach with bindings
* more sync ups tutorial
* more sync ups tutorial
* wip
* more sync ups
* wip
* wip
* Better support for observing copies of values (#2650)
* Explore using _modify
* wip
* wip
* wip
* wip
* wip
* wip
* more tests
* wip
* get another failing test for an edge case
* wip
* tests all passing
* flag for determining when new state was created
* wip
* clean up
* wip
* wip
* wip;
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* New test that currently fails.
* wip
* wip
* Update Sources/ComposableArchitectureMacros/PresentsMacro.swift
* wip
* remove redundant attached member attribute
* storage
* cleanup
* more benchmarks and tests
* wip
* wip
* wip
* wip
* update tests
* wip
* wip
---------
Co-authored-by: Brandon Williams <mbrandonw@hey.com>
* wip
* wip
* wip
* swift-format
* fix
* wip
* wip
* wip
* wip
* Perception
* wip
* wip
* clean up shared state
* fix shared state tests
* wip
* add alert test
* wip
* wip
* wip
* wip
* Use transaction in binding
* wip
* wip
* wip
* wip
* wip
* wip
* uikit
* keep references to controllers when presenting so that we can properly dismiss
* change order of features in shared state demo
* wip
* cleanup
* cleanup
* wip
* wip
* wip
* Fix perception checking for effect actions.
* wip
* wip
* wip
* Fix perception checking for effect actions.
* wip
* wip
* remove sync ups tutorial
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* @Reducer macro will insert protocol requirements if missing
* wip
* fixes
* fix
* wip
* wip
* wip
* docs for observe function for uikit
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* Add cancellation to observation'
* re-record integration test snapshots
* fixed some todos
* update test
* remove 5.9.2 checks
* wip
* wip
* improve docs
* update docs
* updates
* lots of fixes
* more docs
* remove unneeded file;
* wip
* wip
* wip
* update readme and getting started
* wip
* wip
* simplify
* migration stuff
* wip
* Update Models.swift
* wip
* wip
* wip
* Update Bindings.md
* wip
* wip
* wip
* wip
* fix
* wip
* wip
* wip
* wip
* wip
Co-authored-by: Kabir Oberai <oberai.kabir@gmail.com>
* lots of docs and some fixes
* more docs
* more docs
* wip
* upate integration tests to use enum destination macro
* re-org migration guide
* wip
* wip
* docs for other enum reducer macros
* update ephemeral state docs
* wip
* move docs for reducer protocol and macro into single article
* mention observable state
* wip
* updated docs and some macro tests
* wip
* wip
* cleanup
* wip
* wip
* wip
* revert 16
* wip
* clean up
* Revert "clean up"
This reverts commit 49e73081ac.
* Availability fixes
* comment out tests crashing the compiler
* wip
* fix ttt tests
* wip
---------
Co-authored-by: Brandon Williams <mbrandonw@hey.com>
Co-authored-by: Brandon Williams <135203+mbrandonw@users.noreply.github.com>
Co-authored-by: George Scott <gscott@gekkoto.com>
Co-authored-by: Kabir Oberai <oberai.kabir@gmail.com>
24 KiB
Tree-based navigation
Learn about tree-based navigation, that is navigation modeled with optionals and enums, including how to model your domains, how to integrate features, how to test your features, and more.
Overview
Tree-based navigation is the process of modeling navigation using optional and enum state. This style of navigation allows you to deep-link into any state of your application by simply constructing a deeply nested piece of state, handing it off to SwiftUI, and letting it take care of the rest.
Basics
The tools for this style of navigation include the Presents() macro,
PresentationAction, the Reducer/ifLet(_:action:destination:fileID:line:)-4f2at operator,
and that is all. Once your feature is properly integrated with those tools you can use all of
SwiftUI's normal navigation view modifiers, such as sheet(item:), popover(item:), etc.
The process of integrating two features together for navigation largely consists of 2 steps: integrating the features' domains together and integrating the features' views together. One typically starts by integrating the features' domains together. This consists of adding the child's state and actions to the parent, and then utilizing a reducer operator to compose the child reducer into the parent.
For example, suppose you have a list of items and you want to be able to show a sheet to display a
form for adding a new item. We can integrate state and actions together by utilizing the
Presents() macro and PresentationAction type:
@Reducer
struct InventoryFeature {
@ObservableState
struct State: Equatable {
@Presents var addItem: ItemFormFeature.State?
var items: IdentifiedArrayOf<Item> = []
// ...
}
enum Action {
case addItem(PresentationAction<ItemFormFeature.Action>)
// ...
}
// ...
}
Note: The
addItemstate is held as an optional. A non-nilvalue represents that feature is being presented, andnilpresents the feature is dismissed.
Next you can integrate the reducers of the parent and child features by using the
Reducer/ifLet(_:action:destination:fileID:line:)-4f2at reducer operator, as well as having an
action in the parent domain for populating the child's state to drive navigation:
@Reducer
struct InventoryFeature {
@ObservableState
struct State: Equatable { /* ... */ }
enum Action { /* ... */ }
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .addButtonTapped:
// Populating this state performs the navigation
state.addItem = ItemFormFeature.State()
return .none
// ...
}
}
.ifLet(\.$addItem, action: \.addItem) {
ItemFormFeature()
}
}
}
Note: The key path used with
ifLetfocuses on the@PresentationStateprojected value since it uses the$syntax. Also note that the action uses a case path, which is analogous to key paths but tuned for enums, and uses the forward slash syntax.
That's all that it takes to integrate the domains and logic of the parent and child features. Next we need to integrate the features' views. This is done by passing a binding of a store to one of SwiftUI's view modifiers.
For example, to show a sheet from the addItem state in the InventoryFeature, we can hand
the sheet(item:) modifier a binding of a Store as an argument that is focused on presentation
state and actions:
struct InventoryView: View {
@Bindable var store: StoreOf<InventoryFeature>
var body: some View {
List {
// ...
}
.sheet(
item: $store.scope(state: \.addItem, action: \.addItem)
) { store in
ItemFormView(store: store)
}
}
}
Note: We use SwiftUI's
@Bindableproperty wrapper to produce a binding to a store, which can be further scoped usingSwiftUI/Binding/scope(state:action:)-4mj4d.
With those few steps completed the domains and views of the parent and child features are now
integrated together, and when the addItem state flips to a non-nil value the sheet will be
presented, and when it is nil'd out it will be dismissed.
In this example we are using the .sheet view modifier, but every view modifier SwiftUI ships can
be handed a store in this fashion, including popover(item:), fullScreenCover(item:), navigationDestination(item:)`, and more. This should make it possible to use optional state to
drive any kind of navigation in a SwiftUI application.
Enum state
While driving navigation with optional state can be powerful, it can also lead to less-than-ideal modeled domains. In particular, if a feature can navigate to multiple screens then you may be tempted to model that with multiple optional values:
@ObservableState
struct State {
@Presents var detailItem: DetailFeature.State?
@Presents var editItem: EditFeature.State?
@Presents var addItem: AddFeature.State?
// ...
}
However, this can lead to invalid states, such as 2 or more states being non-nil at the same time, and that can cause a lot of problems. First of all, SwiftUI does not support presenting multiple views at the same time from a single view, and so by allowing this in our state we run the risk of putting our application into an inconsistent state with respect to SwiftUI.
Second, it becomes more difficult for us to determine what feature is actually being presented. We
must check multiple optionals to figure out which one is non-nil, and then we must figure out how
to interpret when multiple pieces of state are non-nil at the same time.
And the number of invalid states increases exponentially with respect to the number of features that can be navigated to. For example, 3 optionals leads to 4 invalid states, 4 optionals leads to 11 invalid states, and 5 optionals leads to 26 invalid states.
For these reasons, and more, it can be better to model multiple destinations in a feature as a single enum rather than multiple optionals. So the example of above, with 3 optionals, can be refactored as an enum:
enum State {
case addItem(AddFeature.State)
case detailItem(DetailFeature.State)
case editItem(EditFeature.State)
// ...
}
This gives us compile-time proof that only one single destination can be active at a time.
In order to utilize this style of domain modeling you must take a few extra steps. First you model a
"destination" reducer that encapsulates the domains and behavior of all of the features that you can
navigate to. Typically it's best to nest this reducer inside the feature that can perform the
navigation, and the Reducer() macro can do most of the heavy lifting for us by implementing the
entire reducer from a simple description of the features that can be navigated to:
@Reducer
struct InventoryFeature {
// ...
@Reducer
enum Destination {
case addItem(AddFeature)
case detailItem(DetailFeature)
case editItem(EditFeature)
}
}
Note: The
Reducer()macro takes this simple enum description of destination features and expands it into a fully composed feature that operates on enum state with a case for each feature's state. You can expand the macro code in Xcode to see everything that is written for you.
With that done we can now hold onto a single piece of optional state in our feature, using the
Presents() macro, and we hold onto the destination actions using the
PresentationAction type:
@Reducer
struct InventoryFeature {
@ObservableState
struct State {
@Presents var destination: Destination.State?
// ...
}
enum Action {
case destination(PresentationAction<Destination.Action>)
// ...
}
// ...
}
And then we must make use of the Reducer/ifLet(_:action:destination:fileID:line:)-8qzye operator
to integrate the domain of the destination with the domain of the parent feature:
@Reducer
struct InventoryFeature {
// ...
var body: some ReducerOf<Self> {
Reduce { state, action in
// ...
}
.ifLet(\.$destination, action: \.destination)
}
}
Note: It's not necessary to specify
Destinationin a trialing closure ofifLetbecause it can automatically be inferred due to how theDestinationenum was defined with theReducer()macro.
That completes the steps for integrating the child and parent features together.
Now when we want to present a particular feature we can simply populate the destination state
with a case of the enum:
case addButtonTapped:
state.destination = .addItem(AddFeature.State())
return .none
And at any time we can figure out exactly what feature is being presented by switching or otherwise
destructuring the single piece of destination state rather than checking multiple optional values.
The final step is to make use of the library's scoping powers to focus in on the Destination
domain and further isolate a particular case of the state and action enums via dot-chaining.
For example, suppose the "add" screen is presented as a sheet, the "edit" screen is presented
by a popover, and the "detail" screen is presented in a drill-down. Then we can use the
.sheet(item:), .popover(item:), and .navigationDestination(item:) view modifiers that come
from SwiftUI to have each of those styles of presentation powered by the respective case of the
destination enum.
To do this you must first hold onto the store in a bindable manner by using the @Bindable property
wrapper:
struct InventoryView: View {
@Bindable var store: StoreOf<InventoryFeature>
// ...
}
And then in the body of the view you can use the SwiftUI/Binding/scope(state:action:)-4mj4d
operator to derive bindings from $store:
var body: some View {
List {
// ...
}
.sheet(
item: $store.scope(
state: \.destination?.addItem,
action: \.destination.addItem
)
) { store in
AddFeatureView(store: store)
}
.popover(
item: $store.scope(
state: \.destination?.editItem,
action: \.destination.editItem
)
) { store in
EditFeatureView(store: store)
}
.navigationDestination(
item: $store.scope(
state: \.destination?.detailItem,
action: \.destination.detailItem
)
) { store in
DetailFeatureView(store: store)
}
}
With those steps completed you can be sure that your domains are modeled as concisely as possible.
If the "add" item sheet was presented, and you decided to mutate the destination state to point
to the .detailItem case, then you can be certain that the sheet will be dismissed and the
drill-down will occur immediately.
API Unification
One of the best features of tree-based navigation is that it unifies all forms of navigation with a
single style of API. First of all, regardless of the type of navigation you plan on performing,
integrating the parent and child features together can be done with the single
Reducer/ifLet(_:action:destination:fileID:line:)-4f2at operator. This one single API services
all forms of optional-driven navigation.
And then in the view, whether you are wanting to perform a drill-down, show a sheet, display
an alert, or even show a custom navigation component, all you need to do is invoke an API that
is provided a store focused on some PresentationState and PresentationAction. If you do
that, then the API can handle the rest, making sure to present the child view when the state
becomes non-nil and dismissing when it goes back to nil.
This means that theoretically you could have a single view that needs to be able to show a sheet, popover, drill-down, alert and confirmation dialog, and all of the work to display the various forms of navigation could be as simple as this:
.sheet(
item: $store.scope(state: \.addItem, action: \.addItem)
) { store in
AddFeatureView(store: store)
}
.popover(
item: $store.scope(state: \.editItem, action: \.editItem)
) { store in
EditFeatureView(store: store)
}
.navigationDestination(
item: $store.scope(state: \.detailItem, action: \.detailItem)
) { store in
DetailFeatureView(store: store)
}
.alert(
$store.scope(state: \.alert, action: \.alert)
)
.confirmationDialog(
$store.scope(state: \.confirmationDialog, action: \.confirmationDialog)
)
In each case we provide a store scoped to the presentation domain, and a view that will be presented
when its corresponding state flips to non-nil. It is incredibly powerful to see that so many
seemingly disparate forms of navigation can be unified under a single style of API.
Backwards compatible availability
Depending on your deployment target, certain APIs may be unavailable. For example, if you target
iOS 16, you will not have access to iOS 17's navigationDestination(item:) view modifier. You can
easily backport the tool to work on older platforms by defining a wrapper for the API that calls
down to the available navigationDestination(isPresented:) API. Just paste the following into your
project:
extension View {
@available(iOS, introduced: 16, deprecated: 17)
@available(macOS, introduced: 13, deprecated: 14)
@available(tvOS, introduced: 16, deprecated: 17)
@available(watchOS, introduced: 9, deprecated: 10)
@ViewBuilder
func navigationDestinationWrapper<D: Hashable, C: View>(
item: Binding<D?>,
@ViewBuilder destination: @escaping (D) -> C
) -> some View {
navigationDestination(isPresented: item.isPresented) {
if let item = item.wrappedValue
destination(item)
}
}
}
}
fileprivate extension Optional where Wrapped: Hashable {
var isPresented: Bool {
get { self != nil }
set { if !newValue { self = nil } }
}
}
If you target platforms earlier than iOS 16, macOS 13, tvOS 16 and watchOS 9, then you cannot use
navigationDestination at all. Instead you can use NavigationLink, but you must define another
helper for driving navigation off of a binding of data rather than just a simple boolean. Just paste
the following into your project:
@available(iOS, introduced: 13, deprecated: 16)
@available(macOS, introduced: 10.15, deprecated: 13)
@available(tvOS, introduced: 13, deprecated: 16)
@available(watchOS, introduced: 6, deprecated: 9)
extension NavigationLink {
public init<D, C: View>(
item: Binding<D?>,
@ViewBuilder destination: (D) -> C,
@ViewBuilder label: () -> Label
) where Destination == C? {
self.init(
destination: item.wrappedValue.map(destination),
isActive: Binding(
get: { item.wrappedValue != nil },
set: { isActive, transaction in
if !isActive {
item.transaction(transaction).wrappedValue = nil
}
}
),
label: label
)
}
}
Integration
Once your features are integrated together using the steps above, your parent feature gets instant
access to everything happening inside the child feature. You can use this as a means to integrate
the logic of child and parent features. For example, if you want to detect when the "Save" button
inside the edit feature is tapped, you can simply destructure on that action. This consists of
pattern matching on the PresentationAction, then the PresentationAction/presented(_:) case,
then the feature you are interested in, and finally the action you are interested in:
case .destination(.presented(.editItem(.saveButtonTapped))):
// ...
Once inside that case you can then try extracting out the feature state so that you can perform additional logic, such as closing the "edit" feature and saving the edited item to the database:
case .destination(.presented(.editItem(.saveButtonTapped))):
guard case let .editItem(editItemState) = self.destination
else { return .none }
state.destination = nil
return .run { _ in
self.database.save(editItemState.item)
}
Dismissal
Dismissing a presented feature is as simple as nil-ing out the state that represents the
presented feature:
case .closeButtonTapped:
state.destination = nil
return .none
In order to nil out the presenting state you must have access to that state, and usually only the
parent has access, but often we would like to encapsulate the logic of dismissing a feature to be
inside the child feature without needing explicit communication with the parent.
SwiftUI provides a wonderful tool for allowing child views to dismiss themselves from the parent,
all without any explicit communication with the parent. It's an environment value called dismiss,
and it can be used like so:
struct ChildView: View {
@Environment(\.dismiss) var dismiss
var body: some View {
Button("Close") { self.dismiss() }
}
}
When self.dismiss() is invoked, SwiftUI finds the closet parent view with a presentation, and
causes it to dismiss by writing false or nil to the binding that drives the presentation. This
can be incredibly useful, but it is also relegated to the view layer. It is not possible to use
dismiss elsewhere, like in an observable object, which would allow you to have nuanced logic
for dismissal such as validation or async work.
The Composable Architecture has a similar tool, except it is appropriate to use from a reducer,
where the rest of your feature's logic and behavior resides. It is accessed via the library's
dependency management system (see doc:DependencyManagement) using DismissEffect:
@Reducer
struct Feature {
@ObservableState
struct State { /* ... */ }
enum Action {
case closeButtonTapped
// ...
}
@Dependency(\.dismiss) var dismiss
var body: some Reducer<State, Action> {
Reduce { state, action in
switch action {
case .closeButtonTapped:
return .run { _ in await self.dismiss() }
}
}
}
}
Note: The
DismissEffectfunction is async which means it cannot be invoked directly inside a reducer. Instead it must be called fromEffect/run(priority:operation:catch:fileID:line:).
When self.dismiss() is invoked it will nil out the state responsible for presenting the feature
by sending a PresentationAction/dismiss action back into the system, causing the feature to be
dismissed. This allows you to encapsulate the logic for dismissing a child feature entirely inside
the child domain without explicitly communicating with the parent.
Note: Because dismissal is handled by sending an action, it is not valid to ever send an action after invoking
dismiss():return .run { send in await self.dismiss() await send(.tick) // ⚠️ }To do so would be to send an action for a feature while its state is
nil, and that will cause a runtime warning in Xcode and a test failure when running tests.
Warning: SwiftUI's environment value
@Environment(\.dismiss)and the Composable Architecture's dependency value@Dependency(\.dismiss)serve similar purposes, but are completely different types. SwiftUI's environment value can only be used in SwiftUI views, and this library's dependency value can only be used inside reducers.
Testing
A huge benefit of properly modeling your domains for navigation is that testing becomes quite easy. Further, using "non-exhaustive testing" (see doc:Testing#Non-exhaustive-testing) can be very useful for testing navigation since you often only want to assert on a few high level details and not all state mutations and effects.
As an example, consider the following simple counter feature that wants to dismiss itself if its count is greater than or equal to 5:
@Reducer
struct CounterFeature {
@ObservableState
struct State: Equatable {
var count = 0
}
enum Action {
case decrementButtonTapped
case incrementButtonTapped
}
@Dependency(\.dismiss) var dismiss
var body: some Reducer<State, Action> {
Reduce { state, action in
switch action {
case .decrementButtonTapped:
state.count -= 1
return .none
case .incrementButtonTapped:
state.count += 1
return state.count >= 5
? .run { _ in await self.dismiss() }
: .none
}
}
}
}
And then let's embed that feature into a parent feature using the Presents() macro,
PresentationAction type and Reducer/ifLet(_:action:destination:fileID:line:)-4f2at
operator:
@Reducer
struct Feature {
@ObservableState
struct State: Equatable {
@Presents var counter: CounterFeature.State?
}
enum Action {
case counter(PresentationAction<CounterFeature.Action>)
}
var body: some Reducer<State, Action> {
Reduce { state, action in
// Logic and behavior for core feature.
}
.ifLet(\.$counter, action: \.counter) {
CounterFeature()
}
}
}
Now let's try to write a test on the Feature reducer that proves that when the child counter
feature's count is incremented above 5 it will dismiss itself. To do this we will construct a
TestStore for Feature that starts in a state with the count already set to 3:
func testDismissal() {
let store = TestStore(
initialState: Feature.State(
counter: CounterFeature.State(count: 3)
)
) {
CounterFeature()
}
}
Then we can send the .incrementButtonTapped action in the counter child feature to confirm
that the count goes up by one:
await store.send(.counter(.presented(.incrementButtonTapped))) {
$0.counter?.count = 4
}
And then we can send it one more time to see that the count goes up to 5:
await store.send(.counter(.presented(.incrementButtonTapped))) {
$0.counter?.count = 5
}
And then we finally expect that the child dismisses itself, which manifests itself as the
PresentationAction/dismiss action being sent to nil out the counter state, which we can
assert using the TestStore/receive(_:timeout:assert:file:line:)-6325h method on TestStore:
await store.receive(\.counter.dismiss) {
$0.counter = nil
}
This shows how we can write very nuanced tests on how parent and child features interact with each other.
However, the more complex the features become, the more cumbersome testing their integration can be.
By default, TestStore requires us to be exhaustive in our assertions. We must assert on how
every piece of state changes, how every effect feeds data back into the system, and we must make
sure that all effects finish by the end of the test (see doc:Testing for more info).
But TestStore also supports a form of testing known as "non-exhaustive testing" that allows you
to assert on only the parts of the features that you actually care about (see
doc:Testing#Non-exhaustive-testing for more info).
For example, if we turn off exhaustivity on the test store (see TestStore/exhaustivity) then we
can assert at a high level that when the increment button is tapped twice that eventually we receive
a dismiss action:
func testDismissal() {
let store = TestStore(
initialState: Feature.State(
counter: CounterFeature.State(count: 3)
)
) {
CounterFeature()
}
store.exhaustivity = .off
await store.send(.counter(.presented(.incrementButtonTapped)))
await store.send(.counter(.presented(.incrementButtonTapped)))
await store.receive(\.counter.dismiss)
}
This essentially proves the same thing that the previous test proves, but it does so in much fewer lines and is more resilient to future changes in the features that we don't necessarily care about.
That is the basics of testing, but things get a little more complicated when you leverage the concepts outlined in doc:TreeBasedNavigation#Enum-state in which you model multiple destinations as an enum instead of multiple optionals. In order to assert on state changes when using enum state you must be able to extract the associated state from the enum, make a mutation, and then embed the new state back into the enum.
The library provides a tool to perform these steps in a single step. It's the
PresentationState/subscript(case:)-7uqte defined on PresentationState which allows you to
modify the data inside a case of the destination enum:
await store.send(.destination(.presented(.counter(.incrementButtonTapped)))) {
$0.$destination[case: \.counter]?.count = 4
}
Further, if destination is not of the .counter case when this test runs, then it will trigger
a test failure letting you know that you cannot modify an unrelated case.