EP 224 · Composable Navigation · Feb 27, 2023 ·Members

Video #224: Composable Navigation: Sheets

smart_display

Loading stream…

Video #224: Composable Navigation: Sheets

Episode: Video #224 Date: Feb 27, 2023 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep224-composable-navigation-sheets

Episode thumbnail

Description

We tackle a more complex form of navigation: sheets! We’ll start with the tools the Composable Architecture ships today before greatly simplifying them, taking inspiration from the tools we built for alerts and dialogs.

Video

Cloudflare Stream video ID: 90cfe4484919ba4d317f68083b1ca2c3 Local file: video_224_composable-navigation-sheets.mp4 *(download with --video 224)*

References

Transcript

0:05

OK, things are looking pretty good. We’ve cooked up new reducer operators that can hide away details, such as having an explicit dismiss action that can be sent when wanting to get rid of an alert, as well as automatically clearing out state when an alert is interacted with. It requires a little bit of upfront work to make use of this API, but the benefits really start to roll in once we consider testing. The Composable Architecture keeps us in check every step of the way to make sure that we are asserting on how your feature evolves.

0:37

But, there are still some things to not really like about what we are doing. The main thing is that when we went to expand this API to also include confirmation dialogs, which for all intents and purposes are basically equivalent to alerts, we just copy and pasted everything and renamed it. That’s a pretty big bummer, and there should of course be a way to unify those two things.

0:57

Also, we are currently modeling the alert and confirmation dialog as two separate pieces of optional state, which as we’ve seen time and time again on Point-Free, is not ideal. This allows for non-sensical combinations of state, such as both being non- nil . Ideally we should be able to model destinations as a single enum so that we have compile-time proof that only one destination can be active at a time. Stephen

1:26

But we aren’t going to address any of that just yet. If we do just a little bit more exploring we can come up with another form of navigation, and seeing these shapes for a third time will help us solidify an API that goes well beyond just alerts and dialogs.

1:40

And that next form of navigation is sheets.

1:43

Sheets are a great example of state-driven navigation in SwiftUI. They can be powered by some boolean state to control showing or hiding a view, but even more powerful is to control presentation with a piece of optional state, so that when the state becomes non- nil the view is presented, and when it turns nil it is dismissed.

1:59

We want to bring this power to features built in the Composable Architecture, but instead of mere optional state powering navigation, we want the optionality of an entire feature’s domain to control presentation.

2:09

In particular, the feature we want to show in a sheet is the “add item” view. This is the view that allows you to fill out the details for a new item to add to the inventory, and then you can either add it or discard it.

2:22

Let’s try implementing this feature with just the tools that the Composable Architecture gives us, see why they are lacking, and then we will fix them. The “add item” feature

2:32

Let’s start by getting the “add item” feature implemented so that we can then try to figure out how it can be best plugged into the parent domain.

2:38

The “add item” feature is actually quite basic. In fact, as it stands now, it may not even need to be built in the Composable Architecture. It really just consists of a simple form that can mutate an Item . However, in the future it may get more complicated, such as performing network requests to load more information about the item, or tracking analytics, or who knows what else.

2:57

So we are going to go ahead and get a stub of a basic Composable Architecture domain for this feature. Because this view is actually going to be used for both adding a new item and editing an existing item, we are going to genericize the name a bit and just call it “item form” feature: import ComposableArchitecture struct ItemFormFeature: Reducer { struct State: Equatable { var item: Item } enum Action: Equatable { } var body: some ReducerOf<Self> { EmptyReducer() } }

3:25

And then we will have a view that will hold onto a store of this domain: import SwiftUI struct ItemFormView: View { let store: StoreOf<ItemFormFeature> var body: some View { } }

3:33

At the root of this view we will observe all the state in the feature: var body: some View { WithViewStore(self.store, observe: { $0 }) { viewStore in } }

3:44

As we mentioned in the last episode, you don’t always want to observe all state. Typically you want to just observe a small subset of state because your feature will hold onto things the view doesn’t immediately need, such as other child features. That isn’t the case here because this is just a leaf feature, so this is fine.

4:01

At the root of this view we will put in a Form , and the first field in the form can be a text field for editing the name of the item: Form { TextField("Name", text: <#Binding<String>#>) }

4:15

But, the question is: how do we derive a binding to the name of the item that is held in state?

4:21

In the Composable Architecture you are not allowed to just willy nilly make changes to state. Changes can only be made if an action is sent into the system and processed by the reducer.

4:30

So, that may lead you to think you need a setName action: enum Action: Equatable { case setName(String) }

4:38

And then you would handle that action in the reducer: var body: some ReducerOf<Self> { Reduce { state, action in switch action { case let .setName(name): state.item.name = name return .none } } }

4:54

And with that you can now derive a binding that reads from the view store’s state, but when the binding is written to it will secretly send this action to the store: TextField( "Name", text: viewStore.binding( get: \.item.name, send: ItemFormFeature.Action.setName ) )

5:09

That technically works, but it’s also a mouthful. And we are going to need an action for every little field in the item we want to change, such as the color, status, and whatever other fields there are.

5:22

Luckily there is a better way.

5:25

We can mark the item field in the feature’s state as @BindingState : struct ItemFormFeature: Reducer { struct State: Equatable { @BindingState var item: Item } … }

5:30

…which makes it possible to mutate any part of this state in a more direct fashion.

5:34

In particular, we can mark our Action enum as conforming to the BindableAction protocol, which requires us to provide a binding case: enum Action: BindableAction, Equatable { case binding(BindingAction<State>) // case setName(String) } This one single case will serve the purpose that multiple actions would serve for setName , setColor , setStatus , etc.

5:47

And then we need to compose the BindingReducer into our feature, which will take care of the logic of when a binding action is sent, it will make the mutation: var body: some ReducerOf<Self> { BindingReducer() EmptyReducer() }

6:01

That little bit of upfront work allows us to derive bindings in a super short, succinct syntax: TextField("Name", text: viewStore.binding(\.$item.name))

6:19

Next we can add a UI component for allowing the user to change the color of the item. We will utilize SwiftUI’s Picker view to accomplish this, which takes a binding too. We will paste in the solution since the actual details aren’t really important to us: HStack { Picker( "Color", selection: viewStore.binding(\.$item.color) ) { Text("None") .tag(Item.Color?.none) ForEach(Item.Color.defaults) { color in ZStack { RoundedRectangle(cornerRadius: 4) .fill(color.swiftUIColor) Label(color.name, systemImage: "paintpalette") .padding(4) } .fixedSize(horizontal: false, vertical: true) .tag(Optional(color)) } } if let color = viewStore.item.color { Rectangle() .frame(width: 30, height: 30) .foregroundColor(color.swiftUIColor) .border(Color.black, width: 1) } }

6:45

Next we have the control for switching between being in stock and out of stock, and further within each of those cases there’s some functionality. If the item is in stock you get to change the quantity, and if the item is out of stock you get to change whether or not the item is on back order: public enum Status: Equatable { case inStock(quantity: Int) case outOfStock(isOnBackOrder: Bool) }

7:04

We talked a lot about this kind of control in our previous episodes on vanilla SwiftUI because it poses quite an interesting conundrum for vanilla SwiftUI. Unfortunately SwiftUI does not come with the tools necessary to derive a binding to each of the cases for an enum.

7:17

This is what led us to develop our own tools, called the Switch and CaseLet views, which were released as part of our SwiftUI Navigation library , and we can even make use of them right now even though the rest of our application is built in the Composable Architecture import SwiftUINavigation … Switch(viewStore.binding(\.$item.status)) { CaseLet(/Item.Status.inStock) { $quantity in Section(header: Text("In stock")) { Stepper("Quantity: \(quantity)", value: $quantity) Button("Mark as sold out") { viewStore.send( .set( \.$item.status, .outOfStock(isOnBackOrder: false) ), animation: .default ) } } } CaseLet(/Item.Status.outOfStock) { $isOnBackOrder in Section(header: Text("Out of stock")) { Toggle("Is on back order?", isOn: $isOnBackOrder) Button("Is back in stock!") { viewStore.send( .set(\.$item.status, .inStock(quantity: 1)), animation: .default ) } } } }

8:43

And we will throw in a preview so that we can make sure it looks reasonable and mostly works: struct ItemForm_Previews: PreviewProvider { static var previews: some View { NavigationStack { ItemFormView( store: Store( initialState: ItemFormFeature.State( item: .headphones ), reducer: ItemFormFeature() ) ) } } }

8:52

Ok, and so far looks great! Integrating “add item” Brandon

9:12

The big question is: how do we plug this feature into our parent feature so that the parent can present and dismiss it?

9:19

Well, let’s take it step-by-step to see how easy it is to use the tools that the Composable Architecture gives us today.

9:27

First we want to model the presentation and dismissal of the sheet with an optional, so maybe we can just hold onto an optional ItemFormFeature.State right in the inventory feature’s state: struct State: Equatable { var addItem: ItemFormFeature.State? … }

9:47

And we’ll also need to integrate the form’s actions into our feature’s action, and so let’s add a case for that: enum Action: Equatable { case addItem(ItemFormFeature.Action) … }

10:00

And we’ll want to integrate the form’s logic and behavior into our feature’s logic and behavior, which means somehow running the ItemFormFeature reducer whenever a form action comes in and the addItem state is non- nil . We could do this in a very ad hoc and verbose manner manner, by literally overriding the addItem actions, checking if state is non- nil , running the reducer, and then bundling the resulting effects into the correct shape: case let .addItem(action): guard var itemFormState = state.addItem else { return .none } let itemFormEffects = ItemFormFeature() .reduce(into: &itemFormState, action: action) state.addItem = itemFormState return itemFormEffects.map(Action.addItem)

11:41

But there is a much simpler, and safer way to do this. We can use a reducer operator called ifLet that allows you to safely run a reducer on an optional piece of state when it is non- nil : .ifLet(\.addItem, action: /Action.addItem) { ItemFormFeature() }

12:26

You just have to provide transformations that show where the child domain lives in the parent domain, and the operator takes care of the rest. Further, it is considered an application error if somehow the reducer receives a child action while the child state is nil , and will even cause a purple, runtime Xcode warning if it happens. And now we can just ignore all .addItem actions in the reducer: case .addItem: return .none

13:04

…but in the future if we wanted to layer on additional functionality to the “item form” feature, this is where we would do it.

13:17

So, that’s mostly everything we have to do to integrate the inventory and item form features together, as far as their domain and logic is concerned.

13:24

Next we can move onto the view. We need a button to tap so that the sheet can be presented, and we will add that to the toolbar of the view: .toolbar { ToolbarItem(placement: .primaryAction) { Button("Add") { viewStore.send(.addButtonTapped) } } }

14:01

When the button is tapped we want to send an action, which means an action must be added to the domain: enum Action: Equatable { case addButtonTapped … }

14:05

And we can handle that action by simply populating the addItem state with some data: case .addButtonTapped: state.addItem = ItemFormFeature.State( item: Item(name: "", status: .inStock(quantity: 1)) ) return .none

14:42

We would hope that just the simple act of populating the state would cause the sheet to come flying up. But, for that to be the case, we gotta use the .sheet view modifier somewhere.

14:47

There are two types of sheets we can use: one takes a binding to a boolean and the other takes a binding to an optional piece of state. Well, we do indeed have optional state to power the sheet, so maybe we should give it a shot: .sheet( item: <#Binding<Identifiable?>#>, content: <#(Identifiable) -> View#> ) We need to construct a binding to some optional data, and the sheet view modifier requires it to even be Identifiable . This is required because if SwiftUI detects that the ID of the state changes, it will dismiss and then re-present the sheet, which is a nice little added bonus.

15:33

The way to derive bindings in the Composable Architecture is to use the binding method on viewStore : item: viewStore.binding( get: <#(InventoryFeature.State) -> Value#>, send: <#(Value) -> InventoryFeature.Action#> ),

15:40

The get argument is what is used to pull data from the view store, and so we just want to access the addItem state: item: viewStore.binding( get: \.addItem, send: <#(Value) -> InventoryFeature.Action#> ),

15:49

Now this won’t work because we aren’t currently observing addItem . We are only observing the items: WithViewStore(self.store, observe: \.items) { viewStore in … }

16:03

We could enlarge our observation by observing everything : WithViewStore(self.store, observe: { $0 }) { viewStore in … }

16:07

But this would be a bad idea because this will cause the entire view to be recomputed anytime anything changes in the ItemFormFeature , which includes every single key stroke that occurs in the view. That will be incredibly inefficient.

16:28

This gives us an opportunity to explore using a dedicated ViewState struct to truly whittle down the feature’s state to the bare essentials needed for the view.

16:37

Let’s get a stub into place: struct InventoryView: View { let store: StoreOf<InventoryFeature> struct ViewState: Equatable { } … }

16:42

Then we will add files to ViewState for the base essentials of what the view needs to do its job. In particular, it needs the collection of items for sure: struct ViewState: Equatable { let items: IdentifiedArrayOf<Item> }

16:56

And then you might think we need also the addItem field: struct ViewState: Equatable { let addItem: ItemFormFeature.State? let items: IdentifiedArrayOf<Item> }

17:05

However, that’s not true. All we need to observe from addItem is its identity changes. That means when the state flips from nil to non- nil , or non- nil to nil , or its ID changes. Those events are what cause the sheet to present or dismiss, or even dismiss and then re-present.

17:35

So, let’s just hold the ID: struct ViewState: Equatable { let addItemID: Item.ID? let items: IdentifiedArrayOf<Item> }

17:44

Observing this state will be much more efficient because it means that no matter what is happening on the inside of ItemFormFeature , the parent will re-compute only when its identity changes.

17:57

Now we will create an initializer for ViewState that computes the view state from the main feature’s state: struct ViewState: Equatable { let addItemID: Item.ID? let items: IdentifiedArrayOf<Item> init(state: InventoryFeature.State) { self.addItemID = state.addItem?.item.id self.items = state.items } }

18:25

With that done we can now update our view store to only observe this state: WithViewStore( self.store, observe: ViewState.init ) { viewStore in … }

18:30

…and we’ll update our ForEach to account for this change: ForEach(viewStore.items) { item in … }

18:50

And with those few steps done we can now derive a binding to the addItemID state, which is what will drive the presentation of the sheet: item: viewStore.binding( get: \.addItemID, send: <#(Value) -> InventoryFeature.Action#> ),

18:59

Next, the send argument is the action that will be sent when something is written to the binding.

19:05

Now this binding is a little different from the bindings we derived over in the form view. In that view pretty much anything could be written to the binding because the user was doing the writing. For example, whatever they typed into text field would be written to the binding.

19:28

That’s not the case here. There is only one single thing SwiftUI could possibly ever write to this binding, and that’s nil . The sheet view modifier deals with a completely generic Item : func sheet<Item: Identifiable, Content: View>( item: Binding<Item?>, onDismiss: (() -> Void)? = nil, content: @escaping (Item) -> Content ) -> some View …and so even if SwiftUI wanted to it couldn’t possibly write a non- nil value to the binding. After all, Item is completely generic and so SwiftUI has no idea how to construct one of those values. Further, the only time SwiftUI can write nil to the binding is when the user swipes down on the sheet to dismiss.

20:01

And so because SwiftUI can only write nil to the binding, we can just use a single concrete action rather than a full blown function that takes any piece of state: item: viewStore.binding( get: \.addItemID, send: .dismissAddItem )

20:13

So, looks like we need to add yet another action to our domain: enum Action: Equatable { case dismissAddItem … }

20:20

And handle the action in the reducer by nil -ing out the addItem state in order to dismiss it: case .dismissAddItem: state.addItem = nil return .none

20:30

If we head back to the view, we have a compiler error: Instance method ‘sheet(item:onDismiss:content:)’ requires that ‘Item.ID’ (aka ‘UUID’) conform to ‘Identifiable’

20:49

The sheet modifier requires the item to be identifiable, but what we are providing is an actual identifier. That’s subtly different, though we don’t really care about that difference right now. We just want to show and hide the sheet based off this optional value.

21:05

Well, there is a quick trick we can perform to wrap the identifier into a package that makes it identifiable , and that’s by using this little Identified wrapper type that comes with our Identified Collections library, and comes in transitively with the Composable Architecture: get: { Identified(<#value: _#>, id: <#_#>) }, It can turn any value into an identified value as long as you provide its identifier. In this case, the identifier is the value itself: get: { $0.addItemID.map { Identified($0, id: \.self) } },

22:03

This is really gross, and if it wasn’t already clear then hopefully it is now crystal clear why the Composable Architecture needs 1st class support for navigation tools. But, let’s keep pushing forward.

22:16

OK, we have now filled in the binding for the sheet view modifier, and the only argument left is the trailing closure that constructs the view to be presented, which is actually handed the unwrapped, non-optional data: .sheet( item: viewStore.binding( get: { $0.addItemID.map { Identified($0, id: \.self) } }, send: .dismissAddItem ) ) { addItemID in }

22:34

Now the funny thing is that there isn’t much we can do with this value being handed to us. We want to construct an ItemFormView inside this trailing closure, but to do so we need a store: ItemFormView(store: <#StoreOf<ItemFormFeature>#>) In fact, we need a store of non -optional ItemFormFeature state. So the value we got doesn’t really help us much in that respect.

22:54

But, there is a tool in the Composable Architecture that can help us derive such a store. It’s called an IfLetStore , and it’s a SwiftUI view that facilitates the process of transforming a store of optional state into a store of non-optional state. It takes two arguments: IfLetStore( <#Store<State?, Action>#>, then: <#(Store<State, Action>) -> View#> ) The first is the store of optional state, and then it takes a trailing closure that is handed the store of non-optional state, and that closure can return a view to show when the state is present.

23:22

So, to hand this view a store of optional state we just need to scope the store down the addItem child feature: IfLetStore( self.store.scope( state: \.addItem, action: InventoryFeature.Action.addItem ) ) { store in … }

23:58

The store we have in the closure is exactly what can be passed to the ItemFormView : IfLetStore( self.store.scope( state: \.addItem, action: InventoryFeature.Action.addItem ) ) { store in ItemFormView(store: store) }

24:12

We’d of course never want to write code like this repeatedly in our applications, but we will be cleaning it up soon enough. And already we can see that this somewhat works.

24:25

Let’s run the app in the simulator, but the first thing we will notice is that there is no “Add” button in the top-right of the screen. That’s because we don’t currently have a navigation stack anywhere in our application.

24:42

Let’s add that to the TabView at the root of the application: TabView( selection: viewStore.binding( send: AppFeature.Action.selectedTabChanged ) ) { … NavigationStack { InventoryView( store: self.store.scope( state: \.inventory, action: AppFeature.Action.inventory ) ) } .tabItem { Text("Inventory") } .tag(Tab.inventory) … }

24:51

Now when we run the simulator we can tap the “Add” button, see the sheet fly up, and then we can swipe down to dismiss. There currently aren’t any buttons in the sheet for cancelling or saving, but we will get to that in a moment.

25:39

The inventory feature should also run in the preview, but there are 2 strange things. First, if we try to run the preview we get an Xcode error: Compiling failed: the compiler is unable to type-check this expression in reasonable time; try breaking up the expression into distinct sub-expressions

25:57

We really don’t know why the application builds just fine for the simulator, and even does so quickly, yet somehow can’t build for the preview.

26:05

The fix for these kinds of problems is to just provide more explicit type information to the compiler so that it has to do less work. One spot we can do that is to give type information for the ViewStore provided by the WithViewStore view: WithViewStore( self.store, observe: ViewState.init ) { ( viewStore: ViewStore<ViewState, InventoryFeature.Action> ) in

26:31

This gets the preview compiling, but of course it is really annoying. The main culprit of this compiler error is the gnarly view modifier we have at the bottom: .sheet( item: viewStore.binding( get: { $0.addItemID.map { Identified($0, id: \.self) } }, send: .dismissAddItem ) ) { _ in IfLetStore( self.store.scope( state: \.addItem, action: InventoryFeature.Action.addItem ) ) { store in ItemFormView(store: store) } }

26:50

Luckily once we get the proper navigation tools in place this call site becomes a lot simpler, and we will no longer need to explicitly type the viewStore , but we will leave it in for now.

27:05

We can also show off that behavior we mentioned a moment ago where if the identity of the data presenting the sheet changes, the sheet will be dismissed and re-presented: Button("Add") { viewStore.send(.addButtonTapped) DispatchQueue.main.asyncAfter(deadline: .now() + 2) { viewStore.send(.addButtonTapped) } }

27:51

Now when we tap “Add” the sheet flys up, and two seconds later it dismisses and… well, it crashes.

27:59

This is yet another weird SwiftUI preview bug. The simulator does not have this behavior, so let’s quickly run in the simulator.

28:10

Now when we tap the “Add” button the sheet flys up, and two seconds later it dismisses and re-presents. This is because the act of sending the addButtonTapped again causes a new Item to be created with a new ID.

28:19

So, things are looking good, but we need additional UI in order to commit to adding the item, or to cancel adding the item. To do that we can wrap the ItemFormView in a NavigationStack , and add some toolbar items: NavigationStack { ItemFormView(store: store) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { viewStore.send(.cancelAddItemButtonTapped) } } ToolbarItem(placement: .primaryAction) { Button("Add") { viewStore.send(.confirmAddItemButtonTapped) } } } .navigationTitle("New item") }

29:22

But, with new toolbar items comes new actions to add to the domain: enum Action: Equatable { case cancelAddItemButtonTapped case confirmAddItemButtonTapped … }

29:28

Which means new actions to implement in the reducer: case .cancelAddItemButtonTapped: state.addItem = nil return .none case .confirmAddItemButtonTapped: defer { state.addItem = nil } guard let item = state.addItem?.item else { return .none } state.items.append(item) return .none

30:35

And now finally the feature works as we expect. We can tap the “Add” button, make some edits to the form, and then hit “Add” to see the item added to the end of the list. Or we can tap “Add” button to see the sheet fly up, and hit the “Cancel” button to dismiss without adding anything to the inventory.

31:00

It’s worth commenting on quickly that some of our viewers may wonder why these cancel and confirm button actions are in the parent domain and not delegate actions in the child domain. Because the child domain may be used in multiple contexts, like not only adding items, but editing and duplicating them, as well, it is far more convenient for the parent to own this part of the domain and layer it on accordingly.

32:19

It’s also worth noting that while we have several actions that do the same thing right now, like dismissAddItem and cancelAddItemButtonTapped , we do not want to squash them down into one. We like to map unique actions from the view into unique actions in the domain, which makes it far easier to layer on additional logic to particular actions in the future, say if you want analytics to distinguish how the add item form was dismissed. The good, bad, and ugly

33:19

Phew, OK. We got the feature working. It looks great when run in the preview, but honestly there is a lot to not like about this code.

33:29

But before enumerating all the things wrong with it, let’s highlight a quick thing that is great about it, and that is its testability. Because we have now integrated two features together, and thanks to the Composable Architecture’s tools for exhaustive testing, we can get deep test coverage on how they work together, basically for free.

33:50

So, let’s start by getting a stub of a test in place: func testAddItem() async { let store = TestStore( initialState: InventoryFeature.State(), reducer: InventoryFeature() ) }

34:13

And let’s emulate the user flow of someone tapping the “Add” button, making some changes to the item, and then confirming to add the item.

34:20

We can start by sending the .addButtonTapped action: await store.send(.addButtonTapped) { }

34:26

And in this trailing closure we need to assert how state changed after sending the action. We do this by mutating $0 , which represents the state before the action was sent, so that it equals the state after .

34:41

The only thing we expect to happen is that the addItem state becomes populated with something non- nil : await store.send(.addButtonTapped) { $0.addItem = ItemFormFeature.State( ) } And that state holds onto a new item with some default properties: await store.send(.addButtonTapped) { $0.addItem = ItemFormFeature.State( item: Item( name: "", status: .inStock(quantity: 1) ) ) }

34:59

However, if we run this test it already fails. In fact, there are two failures.

35:08

The main failure is that the state change does not match the expectation: testAddItem(): A state change does not match expectation: … InventoryFeature.State( addItem: ItemFormFeature.State( _item: Item( id: UUID( − 2B268D97-33B8-4B16-835B-409D496F5B19 + CAAF2023-9FCF-46D7-89DB-701BFA97962C ), name: "", color: nil, status: Item.Status.inStock(quantity: 1) ) ), alert: nil, confirmationDialog: nil, items: [] ) (Expected: −, Actual: +)

35:17

This is happening because secretly in the feature we are generating a new UUID, and so the comparison of that UUID with the one generated in the test can’t possibly match.

35:26

The second failure is just corroboration that something isn’t right because it is telling us that we are accessing a live dependency from a test context: testAddItem(): @Dependency(\.uuid) has no test implementation, but was accessed from a test context: Location: Inventory/Models.swift:17 Key: DependencyValues.UUIDGeneratorKey Value: UUIDGenerator Dependencies registered with the library are not allowed to use their default, live implementations when run from tests. To fix, override ‘uuid’ with a test value. If you are using the Composable Architecture, mutate the ‘dependencies’ property on your ‘TestStore’. Otherwise, use ‘withDependencies’ to define a scope for the override. If you’d like to provide a default value for all tests, implement the ‘testValue’ requirement of the ‘DependencyKey’ protocol.

35:37

The dependency management system that the Composable Architecture uses takes a strong stance that you are simply not allowed to use live dependencies in tests.

35:45

The fix for this is to control the dependency on UUID generation by overriding it when constructing the TestStore : let store = TestStore( initialState: InventoryFeature.State(), reducer: InventoryFeature() ) { $0.uuid = .incrementing }

36:11

Now UUID generation is predictable and deterministic, and in fact we know that when an Item is created in order to show the sheet that it will have the exact UUID of all zeros: await store.send(.addButtonTapped) { $0.addItem = ItemFormFeature.State( item: Item( id: UUID( uuidString: "00000000-0000-0000-0000-000000000000" )!, name: "", status: .inStock(quantity: 1) ) ) }

36:30

And now this test passes.

36:34

Now, there is one kind of strange thing about this test passing. We’ve kind of left the application in a halfway state. We asserted that when tapping the button the sheet comes up, but we haven’t further asserted on what needs to happen to dismiss the sheet.

36:50

This seems to somehow go against the philosophy of testing in the Composable Architecture where we like to be super explicit and exhaustive in describing exactly how our features evolve. There seems to be a missed opportunity here to force us to be more explicit, and luckily for us the final version of our navigation tools will actually make this assertion much stronger. But that will come later.

37:09

Let’s simulate the next step of the user flow, which is typing into the text field to change the name of the item to add. Because the inventory feature and item form feature are integrated together, we can have a conversation with the compiler to navigate to the exact action we want to send: await store.send( .addItem(.set(\.$item.name, "Headphones")) )

37:43

And when that action is sent we expect the item inside addItem to be updated: await store.send( .addItem(.set(\.$item.name, "Headphones")) ) { $0.addItem?.item.name = "Headphones" }

37:57

And then finally we will simulate the user confirming that they want to add the item, and when that happens we expect the addItem state to be nil ’d out in order to dismiss the sheet, and we expect the item to be added to the items array: await store.send(.confirmAddItemButtonTapped) { $0.addItem = nil $0.items = [ Item( id: UUID( uuidString: "00000000-0000-0000-0000-000000000000" )!, name: "Headphones", status: .inStock(quantity: 1) ) ] }

38:26

This test passes, and it is testing the integration of two features, and it does so nearly instantly: Test Suite 'InventoryTests' passed at 2023-01-30 19:20:40.200. Executed 1 test, with 0 failures (0 unexpected) in 0.039 (0.039) seconds

38:36

There is no need whatsoever to fire up a slow and flakey unit test just to test the behavior of a sheet presenting, entering some data into the form, and then confirming the addition of the new item. Unit tests in the Composable Architecture are fully capable of this flow.

38:54

So, that’s a really good thing to like about what we have accomplished so far. Let’s look at the things to not like. We’ll just scan the Inventory.swift file from top-to-bottom.

39:07

The first thing that stands out is that we are back to having an explicit dismiss action in our domain: case dismissAddItem

39:18

…and we have to remember to handle that action in order to nil out the state: case .dismissAddItem: state.addItem = nil return .none

39:28

We had accomplished something quite nice with alerts by hiding away those details. It would be really nice if we could do the same with sheets.

39:36

Next, while it’s nice that we could make use of an existing operator in the library to integrate the child domain into the parent domain: .ifLet(\.addItem, action: /Action.addItem) { ItemFormFeature() }

39:51

…it does look a little out of place when compared to the dedicated reducer operators for alerts and confirmation dialogs: .alert(state: \.alert, action: /Action.alert) .confirmationDialog( state: \.confirmationDialog, action: /Action.confirmationDialog ) .ifLet(\.addItem, action: /Action.addItem) { ItemFormFeature() }

39:58

It would be nice if we had a dedicated reducer operator for sheets to make these 3 operators seem more unified, and that dedicated operator would be the place we could bake in some extra logic, such as clearing out state when dismissing.

40:12

The next annoying thing we encountered was in the view: .sheet( item: viewStore.binding( get: { $0.addItemID.map { Identified($0, id: \.self) } }, send: .dismissAddItem ) ) { _ in IfLetStore( self.store.scope( state: \.addItem, action: InventoryFeature.Action.addItem ) ) { store in … } }

40:16

Where to even begin with this monstrosity.

40:19

We are manually constructing a binding in order to hand it to the .sheet modifier.

40:25

We had to contort ourselves with ViewState to make that possible.

40:28

And then had to contort ourselves again to make it play nicely with the Identifiable protocol.

40:37

Once we were done contorting ourselves the sheet modifier hands us a us an honest piece of data that we never actually use.

40:44

Then in the trailing closure we construct an IfLetStore .

40:46

But to do that we have to manually scope a store, which kinda looks like repeated work from what we did to produce the binding.

40:54

And then only once all of that is done can we finally construct the ItemFormView .

41:02

Further, there is even a glitch in the sheet right now. Let’s run the app in the simulator to see. I’m going to tap the “Add” button to show the sheet, and then I’m going to turn on slow animations so that we can really see the glitch. When I tap the “Cancel” button we will see the screen fully clears out while it is animating down.

41:26

This is happening because tapping “Cancel” clears the state, which causes the IfLetStore to recognize that there is no state to display, and so it shows a blank view instead. We can’t easily fix this problem right now, so we won’t even try.

41:38

Should we really be expected to write code like this every time we want to show a sheet? Sure the testing benefits are pretty great, but at what cost?

41:48

And if all of that wasn’t bad enough, there’s something even worse. Right now if the feature being shown in the sheet kicked off a long living effect, such as a timer, or location manager, or socket connection, or what have you, then dismissing the sheet will not automatically cancel that effect. And the child feature can’t even use onDisappear to cancel the effect because by the time the view has disappeared the state has been nil ’d out, and so there’s no way to run the child feature’s reducer because there’s no longer any state to reduce! A better sheet API Brandon

42:18

Well, luckily for us all of these problems can be fixed. We can actually make it quite nice and mechanical to add a new sheet to an existing feature, everything will remain testable, and you will get a bunch of added benefits for free.

42:36

Let’s attack the first problem that we noticed: needing to explicitly model a dismiss action in our domain so that we had something to send over in the view when the user swipes down to dismiss the sheet. We’ve already run into this twice before with alerts and confirmation dialogs where we modeled a simple action enum with a presented case and a dismiss case, so maybe we just have to repeat that yet again: enum SheetAction<Action> { case dismiss case presented(Action) } extension SheetAction: Equatable where Action: Equatable {}

43:18

This is now the third time we have created basically the same enum. Surely at some point we are going to want to unify all these very similar enums, but right now is not that moment. We are going to continue with yet another domain-specific enum just to see how the sheet API can look like in the Composable Architecture.

43:47

With that type defined we make use of it in our feature’s action, and we no longer need to maintain a dedicated dismiss action: enum Action: Equatable { case addItem(SheetAction<ItemFormFeature.Action>) // case addItem(ItemFormFeature.Action) // case dismissAddItem … }

44:05

Then in the reducer we need to destructure on the addItem case’s dismiss case: case .addItem(.dismiss): state.addItem = nil return .none Also we are getting a great warning here letting us know that the addItem case has already been exhaustively handled above, so we need to move this higher up.

44:23

Ideally this code will be able to completely go away just as we accomplished with alerts. We didn’t have to explicitly nil out alert state, and it just worked automatically under the hood. But we will get to that in a moment.

44:36

Next the ifLet isn’t compiling because it’s no longer true that the ItemFormFeature ’s action can be found in the addItem case. Instead we have another layer we need to extract, which is from the SheetAction case of presented . Now this is where native support for case paths would be really great in Swift because ideally we should be able to just chain onto the case path to further dive into the next case: .ifLet(\.addItem, action: \.addItem?.presented) { ItemFormFeature() }

45:17

But sadly this is not possible today, and so we must append two case paths together using the long-form syntax: .ifLet( \.addItem, action: (/Action.addItem) .appending(path: /SheetAction.presented) ) { ItemFormFeature() } This appended case path represents a case path that first dives into the addItem case and then into the presented case.

45:43

And if this syntax seems a little weird then you should know that it is actually inspired by syntax that Swift has today for appending key paths: (\Item.status).appending(path: \Item.Status.isInStock) Now of course you would never do this, and instead you would just do: \Item.status.isInStock

46:16

…and that is why we would love to have native case path support in Swift.

46:25

But either way, the code does compile, and it will work as it did before, it’s just really ugly. We will be able to fix this soon though.

46:30

The next error we need to fix is down in the view where we are currently referring to a dismissAddItem action that no longer exists. We can fix this by instead referring to the new sheet action: item: viewStore.binding( get: { $0.addItemID.map { Identified($0, id: \.self) } }, send: .addItem(.dismiss) )

46:38

And similarly the store scoping is no longer correct because we have to further scope down to the presented case of the sheet action: IfLetStore( self.store.scope( state: { $0.addItem }, action: { .addItem(.presented($0)) } ) ) { store in … }

47:05

Things now compile and work just as they did before. We don’t seem to have actually accomplished that much. Sure we were able to squirrel away the dismiss action so that we didn’t need to model it explicitly, but we still have to handle that action and the call site of the sheet modifier in the view got way gnarlier.

47:38

Well, the fix to these problems is doing what we did for alerts. We will cook up a custom reducer operator for handling the sheet logic, and we will cook up a custom view modifier for hiding the grossness we are seeing.

47:53

Let’s start with the reducer operator. We can take inspiration from the corresponding alert and confirmationDialog operators we made earlier to see how we might want a sheet operator to look. In particular, we should be able to just tack on a method to the base reducer, and then single out the optional state and actions that drive the sheet. Something like this: .alert(state: \.alert, action: /Action.alert) .confirmationDialog( state: \.confirmationDialog, action: /Action.confirmationDialog ) .sheet(state: \.addItem, action: /Action.addItem) // .ifLet( // \.addItem, // action: (/Action.addItem) // .appending(path: /PresentationAction.presented) // ) { // ItemFormFeature() // }

48:30

Now the sheet method needs one additional piece of information that alerts and confirmation dialogs did not need, and that is we need to specify the reducer to run on the addItem state when it is non- nil . This means the method can take a trailing closure that acts as an entry point into reducer builder syntax so that we can specify what reducer we want to run: .alert(state: \.alert, action: /Action.alert) .confirmationDialog( state: \.confirmationDialog, action: /Action.confirmationDialog ) .sheet(state: \.addItem, action: /Action.addItem) { ItemFormFeature() } // .ifLet( // \.addItem, // action: (/Action.addItem) // .appending(path: /PresentationAction.presented) // ) { // ItemFormFeature() // }

48:54

If we can accomplish this, then it looks much, much better than what we were doing with ifLet and appending case paths together.

49:03

Let’s give it a shot.

49:05

We can see the basic shape of the method in the code above. It takes 3 arguments, a state transformation, an action transformation, and then a trailing closure for a reducer to run: extension Reducer { func sheet( state: action: child: ) -> some Reducer { } }

49:32

The state transformation needs to be a key path that isolates a piece of optional state that will control the presentation and dismissal of the sheet. So, we need to introduce a generic for that: extension Reducer { func sheet<ChildState>( state: WritableKeyPath<State, ChildState?>, … ) -> some Reducer { … } }

49:52

The action transformation needs to be a case path to isolate a case of the parent domain’s actions that holds onto a sheet action: extension Reducer { func sheet<ChildState, ChildAction>( state: WritableKeyPath<State, ChildState?>, action: CasePath<Action, SheetAction<ChildAction>>, … ) -> some Reducer { … } }

50:08

And the third argument needs to be a closure that doesn’t take any arguments, but does provide a @ReducerBuilder context and returns some kind of reducer in the presentation domain: extension Reducer { func sheet<ChildState, ChildAction>( state: WritableKeyPath<State, ChildState?>, action: CasePath<Action, SheetAction<ChildAction>>, @ReducerBuilder<ChildState, ChildAction> child: () -> some Reducer<ChildState, ChildAction> ) -> some Reducer { … } }

50:35

And let’s provide internal names for the key path and case path arguments since they will clash with the state and action arguments we need to eventually operate on: extension Reducer { func sheet<ChildState, ChildAction>( state stateKeyPath: WritableKeyPath<State, ChildState?>, action actionCasePath: CasePath<Action, SheetAction<ChildAction>>, @ReducerBuilder<ChildState, ChildAction> child: () -> some Reducer<ChildState, ChildAction> ) -> some Reducer { … } }

50:47

Now we can start to think about the reducer we want to actually return here. First we can update the some Reducer to specify the generics, because we do want to return a reducer that deals with the actions and state of the parent domain: extension Reducer { func sheet<ChildState, ChildAction>( … ) -> some ReducerOf<Self> { … } }

51:05

And technically we should be constructing a whole new reducer conformance to return here, and most likely it could even be private to the library, but we are going to take a shortcut. We are just going to open up a Reduce reducer directly in here: Reduce { state, action in .none }

51:34

Amazingly this compiles, and even the theoretical syntax we sketched compiles: .sheet(state: \.addItem, action: /Action.addItem) { ItemFormFeature() }

51:39

So, if we can just implement this reducer we will have cleaned things up quite a bit.

51:42

So, what logic do we want to run inside this sheet operator?

51:46

Well, that largely depends on two factors: is the incoming action a sheet action that needs additional logic layered on, and is the child state nil or non- nil ? That means there are technically 4 different combinations we need to handle.

52:00

Well, we can use switch to force ourselves to exhaustive handle all of those combinations: Reduce { state, action in switch ( state[keyPath: stateKeyPath], actionCasePath.extract(from: action) ) { } }

52:22

The easiest combination to deal with here when the incoming action isn’t even a sheet action. In that case we can just run the parent reducer and we don’t need to layer on any more logic: case (_, .none): return self.reduce(into: &state, action: action)

52:49

The next easiest combination to deal with is when a sheet action comes into the system but the child state is nil : case (.none, .some(.presented)), (.none, .some(.dismiss)): return self.reduce(into: &state, action: action)

53:14

However, it’s not correct to just run the parent reducer and pretend everything is OK, but things are not OK in this scenario.

53:23

If a sheet action is sent while the child state is nil then something bad must have happened. We consider this to be a programmer error and it usually happens when a child feature goes away and does not tear down its effects properly, and then at a later time the effect tries sending an action back into the system. Such actions can’t actually do anything because there is no child state to reduce on, and so this can cause subtle, silent bugs, and you most likely want to know about that.

53:50

This is why the ifLet performs runtime warnings and causes test failures if it detects actions being sent while state is nil . So, let’s do the same here by causing a test failure: case (.none, .some(.presented)), (.none, .some(.dismiss)): XCTFail( "A sheet action was sent while child state was nil." ) return self.reduce(into: &state, action: action)

54:24

We will soon see that there is a really nice way to tear down child effects when the child features go away, and so these kinds of problems should happen less and less frequently.

54:35

And the final combination to handle is when there is non- nil child state to operate on and a sheet action comes through. We will even split the sheet action into its two cases, presented and dismiss , because they have different logic. If we just put fatalError ’s in for now we will see that we have satisfied Swift’s exhaustive checker: case let (.some(childState), .presented(childAction)): fatalError() case let (.some(childState), .dismiss): fatalError()

55:09

Let’s provide the proper implementations for these cases.

55:13

When a dismiss action comes in while we have some non- nil state, we just need to nil out the state: case (.some, .dismiss): state[keyPath: stateKeyPath] = nil return self.reduce(into: &state, action: action)

55:28

And this is an implementation, but we’re starting to see some subtleties of how these navigation tools should work. What if during dismiss we want to give the parent the ability to take a final peek at the child state before it goes nil . Well we need to run it before : case (.some, .dismiss): let effects = self.reduce(into: &state, action: action) state[keyPath: stateKeyPath] = nil return effects

56:16

Now we have the final case, when the child state is non- nil and a child action comes into the system. We have to do a few things in this case. First we need to run the child reducer on that child state and child action, but first we need to grab a mutable version of the child state: case (.some(var childState), .presented(let childAction)): let childEffects = child() .reduce(into: &childState, action: childAction)

56:45

Once the child reducer runs we need to make sure to update the child state inside the parent domain: state[keyPath: stateKeyPath] = childState

56:57

Next we will run the parent reducer on the parent state and action: let effects = self.reduce(into: &state, action: action)

57:02

And finally we will merge the child and parent effects together, but we must make sure to bundle up any child actions emitted by the child effect into a parent action: return .merge( childEffects.map { actionCasePath.embed(.presented($0)) }, effects ) We do this bundling by first wrapping the child effect in a presented sheet action, and then further wrapping it in the parent action using the embed functionality of the action case path.

57:44

We do have an error because the child reducer builder closure needs to be escaping, but also that’s not really necessary. We can just evaluate it a single time outside the Reduce so that we don’t incur the cost of an escaping closure: let child = child() return Reduce { state, action in … }

58:16

Now we can simply do: let childEffects = child .reduce(into: &childState, action: childAction)

58:19

It’s worth noting that we are using both the extract and embed functionality of the case path. There has been some talk in Swift evolution for adding the beginnings of case path functionality to the language, but sadly it is only focused on the extraction part for right now.

58:52

While that would be handy, it’s also missing the forest from the trees. Key paths in Swift would not be nearly as powerful as they are today if they were only sugar for getters. The fact that they can bundle up the concepts of getting and setting is why they are so powerful, and so case paths would be severely limited if they only had extraction functionality.

59:16

OK, with that said, things are compiling, and things should work exactly as they did before, even though we commented out the dismissal logic up in the core reducer.

1:00:05

So, we have now simplified how one adds a sheet to a Composable Architecture feature by providing a dedicated sheet reducer operator that is capable of hiding a lot of the logic under the hood. We no longer need to model an explicit dismiss action, and we no longer need to implement the logic for when that action is sent.

1:00:33

Next we need to clean up that mess we created over the view. Currently it looks like this: .sheet( item: viewStore.binding( get: { $0.addItemID.map { Identified($0, id: \.self) } }, send: .addItem(.dismiss) ) ) { _ in IfLetStore( self.store.scope( state: \.addItem, action: { .addItem(.presented($0)) } ) ) { store in … } }

1:00:43

But taking some inspiration from what we have done for alerts and confirmation dialogs, maybe it should look more like this: .sheet( store: self.store.scope( state: \.addItem, action: InventoryFeature.Action.addItem ) ) { store in … } You just give it a store that isolates the optional state you want to drive navigation and isolates the action for the sheet, and it will do the rest. In particular, there’s no need to construct a binding, or specify a dismiss action, or deal with an IfLetStore , or mess with that weird coalescing state, or no need to nest actions just to get things to compile. All of that will be hidden from us.

1:01:32

Let’s see what it takes to implement this.

1:01:35

We can start by getting a very basic method in place: extension View { func sheet( ) -> some View { } }

1:01:46

This method needs to take two arguments, first a store that holds onto some optional state and deals with sheet actions: extension View { func sheet<ChildState, ChildAction>( store: Store<ChildState?, SheetAction<ChildAction>> ) -> some View { } }

1:02:00

And second a @ViewBuilder trailing closure for specifying what view to show in the sheet. Most importantly, this closure will take a store that deals with honest, non-optional child state and regular child actions: extension View { func sheet<ChildState, ChildAction>( store: Store<ChildState?, SheetAction<ChildAction>>, @ViewBuilder child: @escaping (Store<ChildState, ChildAction>) -> some View ) -> some View { } }

1:02:32

Then the work we do inside this method will look a lot like what we were doing above, so we can just copy-and-paste it and make a few changes.

1:02:59

The first thing we need to do is wrap everything in a WithViewStore because we need to be able to access state in the store and send actions: WithViewStore(store, observe: { $0 }) { viewStore in }

1:03:16

Currently this observes everything , but we can do a lot better.

1:03:32

First, we know the sheet modifier requests identifiable state, so we are going to copy that: func sheet< ChildState: Identifiable, ChildAction, Child: View >( … )

1:03:39

And with that we don’t need to observe every little change in the child feature, but instead just when the identity of the child feature changes: WithViewStore(store, observe: { $0?.id }) { viewStore in } This will be much more efficient.

1:03:53

We can derive a binding to the view store, but now we will take the dismiss action from the SheetAction enum rather than requiring it to be modeled directly in the parent domain: self.sheet( item: viewStore.binding( get: { $0.map { Identified($0, id: \.self) } }, send: .dismiss ) ) { _ in }

1:04:14

Then inside the trailing closure of the sheet modifier we will update the IfLetStore : IfLetStore( store.scope( state: { $0 }, action: SheetAction.presented ) ) { store in child(store) }

1:04:54

Everything is almost compiling, we just have to make ItemFormFeature.State identifiable, which we can do by plucking off the ID of the underlying item: struct State: Equatable, Identifiable { @BindingState var item: Item var id: Item.ID { self.item.id } }

1:05:43

And just like everything is compiling, even the theoretical syntax we sketched above. And it should work exactly as before.

1:05:51

But, while we’re here, we can easily make a fix to a small glitch we noticed before. Remember that when you tap “Cancel” or “Add” in the sheet, the contents of the sheet go away while the sheet is animating away. That is a little jarring, and ideally the contents would stay the entire time the screen is animating.

1:06:13

The reason this is happening is because navigation is fully driven off of state. So, the way to get dismiss the sheet is to nil out the state, but then the act of doing that causes the UI to go blank.

1:06:34

Well, we can simply keep around the last non- nil child state value we saw and always provide it to the view so that even when the state is nil ’d out, causing the sheet to dismiss, it will continue showing the last non- nil state.

1:06:52

We will do this with a little helper function that can turn any optional returning function into one that defaults to the last non- nil value seen: func returningLastNonNilValue<A, B>( _ f: @escaping (A) -> B? ) -> (A) -> B? { var lastValue: B? return { a in lastValue = f(a) ?? lastValue return lastValue } } And then we can apply that to the scoped store we hand to the child view: IfLetStore( store.scope( state: returningLastNonNilValue { $0 }, action: { .presented($0) } ) ) { store in child(store) }

1:08:24

And just like that we have fixed the glitch we noticed before, though viewers may have noticed a new purple runtime warning, which is actually coming from the XCTFail we added earlier. We are getting a sheet action when child state is nil , specifically the dismiss action. This is similar to the extra dismiss action that alerts were sending to the store, and we can work around the problem in the same way, with a custom binding that only sends dismiss actions when state is still present: item: Binding( get: { viewStore.state.map { Identified($0, id: \.self) } }, set: { newState in if viewStore.state != nil { viewStore.send(.dismiss) } } )

1:10:01

So, everything is now working exactly as it did before, but our code has now gotten a lot simpler. In order to add a sheet to an existing Composable Architecture we must complete the following 4 steps:

1:10:15

Add a piece of optional state to your domain: var addItem: ItemFormFeature.State?

1:10:19

Add a case for the actions of the sheet when it is presented: case addItem(SheetAction<ItemFormFeature.Action>)

1:10:24

Handle this new action in your reducer. Often it is enough to just return .none . You only need to do more work here if you want to layer on additional logic, but that isn’t always needed. case .addItem: return .none

1:10:34

Use the sheet reducer operator in order to enhance your existing reducer with the functionality of the sheet: .sheet(state: \.addItem, action: /Action.addItem) { ItemFormFeature() }

1:10:42

And finally use the new sheet view modifier that turns a store of optional state into a store of honest, non-optional state, which can be passed to the child feature being presented: .sheet( store: self.store.scope( state: \.addItem, action: InventoryFeature.Action.addItem ) ) { store in … }

1:10:50

That’s all it takes!

1:10:53

With those few steps taken you have a child feature that can be built and tested in full isolation, and then plugged into a parent domain as a sheet. We are finally starting to see some of the potential power in these APIs, and things are about to get a lot nicer, but let’s first see how these changes have affected tests.

1:11:12

There is only one single compiler error we have, and it’s where we test the “add item” user flow and we send an action in the child domain: await store.send( .addItem(.set(\.$item.name, "Headphones")) ) { $0.addItem?.item.name = "Headphones" }

1:11:16

This doesn’t compile because we now have an additional layer of actions since the addItem case now holds onto a SheetAction . So, we just have to further destructure the presented case: await store.send( .addItem(.presented(.set(\.$item.name, "Headphones"))) ) { $0.addItem?.item.name = "Headphones" }

1:11:34

This now compiles and the test even passes.

1:11:40

So even with the new infrastructure we have built for handling sheets, the tests don’t change much. We just have to tweak how we send child actions.

1:11:53

And testing isn’t the only benefit to all the tooling we have built so far. We also have the instant ability to deep linking into any state of our application by just constructing some state, handing it off to SwiftUI, and letting SwiftUI do the rest.

1:12:05

For example, we can construct the initial state in the entry point of the application so that the addItem state is already populated: initialState: AppFeature.State( inventory: InventoryFeature.State( addItem: ItemFormFeature.State( item: Item( name: "Laptop", status: .inStock(quantity: 100) ) ), items: [ .monitor, .mouse, .keyboard, .headphones ] ), selectedTab: .inventory )

1:12:35

And now when we launch the application in the simulator we are instantly deep linked into the add item sheet. We can make a few changes, and then tap “Add”, and we will see the new item in the inventory list. Next time: effect cancellation

1:12:47

So things are looking pretty great already, but now we can really start to flex our muscles. Because we have this sheet reducer operator that handles all the details of how to integrate the parent and child domains, we get to layer on super powers with very little work. Stephen

1:13:02

Take effect cancellation as an example. It is very common to bring up a sheet in an application, and for that sheet to fire off effects. Those effects could be long-living, like timers, socket connections, etc., or the effect may just take a long time to finish, such as a slow network request.

1:13:19

We would love if those effects would just be automatically torn down and canceled when the sheet is dismissed. After all, if those effects produce any actions to be fed back into the system after the sheet is dismissed, then those actions will just go into the void. The child feature can’t react to those actions because there is no state to reduce on.

1:13:35

Now currently, with the tools that the Composable Architecture ships today, you do get a little bit of help in this area. If your effect is started from the .task view modifier, which executes when the view appears, then that effect will be torn down when the view disappears.

1:13:50

However, it does not help with all of the effects that can happen at other times, such as when you tap a button to start a timer. That effect is not tied to the lifecycle of the view.

1:13:59

But this sheet operator we have just developed does have the capability to coordinate all of this, and it’s super cool…next time! References Composable navigation beta GitHub discussion Brandon Williams & Stephen Celis • Feb 27, 2023 In conjunction with the release of episode #224 we also released a beta preview of the navigation tools coming to the Composable Architecture. https://github.com/pointfreeco/swift-composable-architecture/discussions/1944 Downloads Sample code 0224-composable-navigation-pt3 Point-Free A hub for advanced Swift programming. Brought to you by Brandon Williams and Stephen Celis . Content Become a member The Point-Free Way Beta previews Gifts Videos Collections Free clips Blog More About Us Community Slack Mastodon Twitter BlueSky GitHub Contact Us Privacy Policy © 2026 Point-Free, Inc. All rights are reserved for the videos and transcripts on this site. All other content is licensed under CC BY-NC-SA 4.0 , and the underlying source code to run this site is licensed under the MIT License .