Video #162: SwiftUI Navigation: Sheets & Popovers, Part 1
Episode: Video #162 Date: Oct 4, 2021 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep162-swiftui-navigation-sheets-popovers-part-1

Description
It’s time to look at a more advanced kind of navigation: modals. We will implement a new feature that will be driven by a sheet and can be deep-linked into. Along the way we’ll introduce a helper to solve a domain modeling problem involving enum state.
Video
Cloudflare Stream video ID: 454961ef79c3cf7a8942da5a3e38856b Local file: video_162_swiftui-navigation-sheets-popovers-part-1.mp4 *(download with --video 162)*
References
- Discussions
- Case Paths
- a new Swift 5.5 feature
- Demystifying SwiftUI
- SwiftUI Navigation
- @StateObject and @ObservedObject in SwiftUI
- 0162-navigation-pt3
- Brandon Williams
- Stephen Celis
- Mastodon
- GitHub
- CC BY-NC-SA 4.0
- source code
- MIT License
Transcript
— 0:05
So, that’s the basics of tab views, alerts and confirmation dialogs in SwiftUI, and it’s all looking pretty promising. We are able to drive these very basic navigation interactions purely with state changes. We can flip a single field to change the selected tab at the root, or we can flip a field to be non- nil and instantly get an alert showing.
— 0:25
We are seeing the beginnings of what it means to model navigation in state as well as how one can instantly support deep linking by just constructing the piece of state that corresponds to the navigation, and just let SwiftUI do the rest.
— 0:38
But so far the “navigation” we have explored is quite simple. Tab views have all of their child views present at once and then a simple binding controls which tab is currently active. And alerts are presented and dismissed via a transient piece of optional state, but the alert itself has no real behavior. It’s just some data and a few buttons that can invoke some actions.
— 1:02
Navigation starts to get really complicated when things can nest so that you can navigate to one screen, and then from there navigate to another screen, and on and on, and each screen picks up behavior of its own. That’s when modeling navigation in state starts to really take the form of a tree.
— 1:19
To get our feet wet in exploring this we are going to start with sheets in SwiftUI, which are modal screens that slide up over the main content of the screen. Sheets sit somewhere between tab views and alerts on the spectrum of navigation. They are presented and dismissed with optional state like alerts, but they can also hold onto complex behavior of their own like tab views.
— 1:41
We need to figure out how we can spin up a new view model to hand to the sheet when it’s presented so that it can have behavior, and then tear down the view model when the sheet is dismissed.
— 1:53
That’s going to take some time to get right, so let’s jump in. Adding inventory items
— 1:57
We are going to place an “Add” button in the top-right of the screen for adding a brand new item to the inventory list. Later we will introduce even more types of modals for editing and duplicating inventory items.
— 2:13
We can put the “Add” button in the top-right by adding a toolbar item to the navigation bar, which can be done using a new API in iOS 15: .toolbar { ToolbarItem(placement: .primaryAction) { Button("Add") { } } }
— 2:53
We can also add a title to the view: .navigationTitle("Inventory")
— 2:59
In order to get this to show in our preview we will wrap it in a NavigationView : struct InventoryView_Previews: PreviewProvider { static var previews: some View { … NavigationView { } } }
— 3:16
And we’ll do the same in the root tab view: NavigationView { InventoryView(viewModel: viewModel.inventoryViewModel) } .tabItem { Text("Inventory") } .tag(Tab.inventory)
— 3:31
Now that we have some infrastructure in place we can explore how to hook up the behavior for these buttons. Let’s start with the “Add” button.
— 3:41
The .sheet API in SwiftUI consists of two overloads, each are quite similar to the deprecated, pre-Xcode 13 alert modifiers, but luckily these APIs are not deprecated: func sheet<Content: View>( isPresented: Binding<Bool>, onDismiss: (() -> Void)? = nil, content: @escaping() -> Content ) -> some View func sheet<Item: Identifiable, Content: View>( item: Binding<Item?>, onDismiss: (() -> Void)? = nil, content: @escaping(Item) -> Content ) -> some View
— 3:55
One takes a boolean binding to determine to show and hide the sheet, and the other takes a binding of an optional. When the optional becomes non- nil the content closure is invoked with the unwrapped value, which we can use to construct some view hierarchy that then causes the modal to animate onto the screen.
— 4:25
The simplest of these APIs is the boolean one. We can add some simple @State to our view: struct InventoryView: View { @ObservedObject var viewModel: InventoryViewModel @State var addItemIsPresented = false … }
— 4:41
And we can invoke the .sheet API by deriving a binding: .sheet(isPresented: $addItemIsPresented) { Text("Add item") }
— 4:54
And then the “Add” button can toggle this boolean: Button("Add") { addItemIsPresented = true }
— 5:01
When we tap the “Add” button we will see the sheet come flying up, and if we pull down the sheet to dismiss it goes away. One interesting thing about the dismissal gesture is that it automatically writes false to the binding when the sheet finishes animating away, and so that guarantees that the state in the InventoryView stays in sync with the visual state of the modal. It should never be possible that the boolean is true and yet the modal is not visible, or vice-versa, that the boolean is false but the modal is visible.
— 5:40
Let’s get something more interesting in the modal. We want to display a form for editing all of the fields of an item. We can get some basic scaffolding in place, such as a Form view that wraps a TextField for the name of the item. struct ItemView: View { var body: some View { Form { TextField("Name", text: .constant("")) } } } struct ItemView_Previews: View { static var previews: some View { ItemView() } }
— 6:27
Right now we are using a constant binding because we don’t have any actual state to hook up this UI control. That’s not very interesting though, so let’s get some real state in place.
— 6:41
We can create a little draft item to mutate in isolation for the lifetime of this view, and then we’ll need to figure out how to communicate this item to the parent when it’s time to save, but we’ll worry about that later: struct ItemView: View { @State var item = Item( name: "", color: nil, status: .inStock(quantity: 1) ) … }
— 7:08
And now the name text field can bind to this value’s name field: TextField("Name", text: $item.name)
— 7:19
We can also construct a picker that allows us to choose from each color in the Color enum: Picker(selection: $item.color, label: Text("Color")) { Text("None") .tag(Item.Color?.none) ForEach(Item.Color.defaults, id: \.name) { color in Text(color.name) .tag(Optional(color)) } }
— 7:47
Note that there is a subtlety in this code where we have to explicitly wrap the color in an Optional otherwise this code won’t work. We’re not used to having to explicitly wrapping things in optionals because Swift usually promotes things for us behind the scenes. But in this case without making sure the tag is optional the picker view will not know which color is being chosen.
— 8:26
When we run this in the preview we will see the picker, but in order to interact with it, we need to wrap things in a navigation view. struct ItemView_Previews: View { static var previews: some View { NavigationView { ItemView() } } }
— 8:49
The last field of the Item model that we need to be able to edit in this form is the status . It is an enum with two cases, and each case has its own associated data. Since it’s an enum it seems that it is natural for us to switch on it, and in case we can create a form section: switch item.status { case let .inStock(quantity: quantity): Section(header: Text("In stock")) { } case let .outOfStock(isOnBackOrder: isOnBackOrder): Section(header: Text("Out of stock")) { } }
— 9:26
For the .inStock case we would like a stepper that allows us to edit the quantity of the item, as well as a button to mark the item as sold out. The button is the easiest part to implement because we just need to mutate the item to set its status to .outOfStock : case let .inStock(quantity: quantity): Section(header: Text("In stock")) { Button("Mark as sold out") { item.status = .outOfStock(isOnBackOrder: false) } }
— 9:49
The stepper however is a little more complicated. In order to create a stepper we need to provide a title and a binding to a strideable value: Stepper(<#LocalizedStringKey#>, value: <#Binding<Strideable>#>)
— 10:07
The strideable value should just be the quantity of the item, but unfortunately that value is wrapped up in a case of the enum. While SwiftUI provides lots of tools for projecting bindings out of existing bindings using dot syntax, which is highly tuned for structs, it offers no such tools for enum. We can however construct the binding manually, though it takes some work: Stepper( "Quantity: \(quantity)", value: Binding( get: { quantity }, set: { self.item.status = .inStock(quantity: $0) } ) )
— 10:52
And we can do something similar for a toggle in the .outOfStock case: Section(header: Text("Out of stock")) { Toggle( "Is on back order?", isOn: Binding( get: { isOnBackOrder }, set: { self.item.status = .outOfStock(isOnBackOrder: $0) } ) ) Button("Is back in stock!") { self.item.status = .inStock(quantity: 1) } }
— 11:15
And we can run the preview, add and remove quantity, mark as out of stock, toggle between whether or not the item is on back order, and mark as being back in stock.
— 11:26
This completes the very basics of this view, and so we can now plug it into the sheet presentation: .sheet(isPresented: $addItemIsPresented) { NavigationView { ItemView() } }
— 11:54
When we run this in the preview we will see that we can bring the modal up, and toggle its status back and forth between in stock and out of stock. We can’t drill down into the picker, because the view needs to be in a navigation view in order to do so, but we’ll fix that in a bit.
— 12:01
So those are the very basics of sheets that are driven off boolean bindings. And we can see them starting to work, but of course we would never want to write code like this. Needing to construct these ad-hoc bindings is tedious, and makes this switch statement take up a lot of code just to get a few UI controls on the screen. Enum binding transformations
— 12:29
The reason this code is so messy is because SwiftUI does not ship with tools for deriving bindings for each case of an enum. It does ship with tools for deriving bindings for each field of a struct. That’s given to us by @dynamicMemberLookup , allowing us to just dot-chain onto a binding to get a whole new binding for any field.
— 12:47
Well, even though SwiftUI doesn’t give us a tool for deriving bindings for each case of an enum, it doesn’t mean we can’t invent this tool ourselves. What if we could cook up a view that hides away all of the messy details of: extracting a value from an enum, building a custom binding that focuses on a case of the enum, and rendering a view for that one case?
— 13:07
So, let’s try to imagine a syntax that could replace all of the messiness we are seeing with switching over the status enum and building a binding from scratch.
— 13:19
Right now, the switch statement is doing a lot of work in what amounts to displaying just a few fields. switch item.status { case let .inStock(quantity: quantity): Section(header: Text("In stock")) { Stepper( "Quantity: \(quantity)", value: Binding( get: { quantity }, set: { self.item.status = .inStock(quantity: $0) } ) ) Button("Mark as sold out") { self.item.status = .outOfStock(isOnBackOrder: false) } } case let .outOfStock(isOnBackOrder: isOnBackOrder): Section(header: Text("Out of stock")) { Toggle( "Is on back order?", isOn: Binding( get: { isOnBackOrder }, set: { self.item.status = .outOfStock(isOnBackOrder: $0) } ) ) Button("Is back in stock!") { self.item.status = .inStock(quantity: 1) } } }
— 13:27
This is almost 30 lines of code, and doesn’t even fit on the screen all at once.
— 13:38
What if there was a view called IfCaseLet that you provide the binding of the enum that you want to try to destructure, as well as the pattern you are trying to match: IfCaseLet( $item.status, pattern: Item.Status.inStock ) { (quantity: Binding<Int>) in Section(header: Text("In stock")) { Stepper("Quantity: \(quantity.wrappedValue)", value: quantity) Button("Mark as sold out") { self.item.status = .outOfStock(isOnBackOrder: false) } } } Under the hood this would check if the status matches the enum case passed in, in this situation .inStock , and if that succeeds it would derive a binding for the associated value held in that case and pass that binding to a view builder closure. Then inside the closure we would be free to use this binding however we want, including accessing its wrapped value or passing it to the Stepper control.
— 14:47
This of course isn’t compiling right now, so let’s try to implement it. It will be a struct that conforms to SwiftUI’s view protocol: struct IfCaseLet: View { }
— 15:01
We can start by defining an initializer that matches what we sketched out earlier. struct IfCaseLet<Enum, Case, Content: View>: View { init( _ binding: Binding<Enum>, pattern: @escaping (Case) -> Enum, @ViewBuilder content: @escaping (Binding<Case>) -> Content ) { } } The Enum generic represents the type of binding we want to destructure, for example the Status enum. The Case generic represents the type of binding we want to derive from the enum binding, for example the quantity integer from the .inStock case. And the Content generic represents the type of view that we build in the closure.
— 16:03
The struct will need to hold onto the information passed to it so that it can make use of it in the body property: struct IfCaseLet<Enum, Case, Content>: View where Content: View { let binding: Binding<Enum> let pattern: (Case) -> Enum let content: (Binding<Case>) -> Content init( _ binding: Binding<Enum>, pattern: @escaping (Case) -> Enum, @ViewBuilder content: @escaping (Binding<Case>) -> Content ) { self.binding = binding self.pattern = pattern self.content = content } }
— 16:26
In the body of the view we need to essentially perform all the work we are currently doing in an ad-hoc manner, but this time it will be general over all types of enums, cases and contents.
— 16:41
The first thing our ad-hoc view code does is match against a particular case. Instead of switching we want to somehow extract a Case value from the Enum value. I’m not quite sure how to do that right now, but let’s pretend we know how to do it for a moment: if let case = (fatalError() as! Case?) { }
— 17:08
If we are able to extract out this case value, we could construct a Binding that returns that case for the get endpoint, and then for the set endpoint we could use the pattern function to embed a new case value into the enum: if let case = (fatalError() as! Case?) { content( Binding( get: { case }, set: { binding.wrappedValue = self.pattern($0) } ) ) }
— 17:44
So, this compiles, but of course we need to figure out how to extract the case from the enum. Is it possible to abstractly extract a particular case from an enum in Swift?
— 17:56
Well, it is possible, but sadly not with the tools that Swift provides us out of the box. But luckily for us we open sourced a library well over a year ago that can accomplish this. It’s called Case Paths , and although it was first introduced as a fundamental tool for aiding composition in the Composable Architecture, it has since then found applications in many other areas, including vanilla SwiftUI.
— 18:18
Case paths are analogous to key paths in Swift, except they are tuned specifically to deal with enums rather than structs. Where every field of a struct is naturally endowed with a key path, which can be derived by just doing backslash, dot and the name of the field, enums on the other hand can be blessed with a case path for each case, and we even provide a helper for automatically deriving case paths using a forward slash operator instead of a backslash. let keyPath = \Item.id let casePath = /Item.Status.inStock
— 19:07
Just as structs and enums are two sides of the same coin, so are key paths and case paths. Key paths allow you to do the two most fundamental operations that one can do on a struct: get the value of a field out of a struct and mutate a struct by setting the field. Case paths similarly are comprised of the two fundamental operations that one repeatedly does with enums: try to extract a case’s value from an enum, and embed a value into the case of an enum.
— 19:34
To get access to case paths we just need to import our library and add the dependency to our application target: import CasePaths
— 20:08
Then we can upgrade the pattern argument to be a full blown case path, rather than just the embed function of the enum: struct IfCaseLet<Enum, Case, Content: View>: View { let binding: Binding<Enum> let casePath: CasePath<Enum, Case> let content: (Binding<Case>) -> Content init( _ binding: Binding<Enum>, pattern casePath: CasePath<Enum, Case>, @ViewBuilder content: @escaping (Binding<Case>) -> Content ) { self.binding = binding self.casePath = casePath self.content = content } … }
— 20:33
And now we can properly handle the extracting logic in the body of the view: var body: some View { if let case = casePath.extract(from: binding.wrappedValue) { content( Binding( get: { case }, set: { binding.wrappedValue = self.casePath.embed($0) } ) ) } }
— 20:57
And now this code is compiling, and we have generalized all the ad hoc work we were doing before.
— 21:03
It’s also worth pointing out how this code shows that deriving bindings from case paths is completely analogous to deriving bindings from key paths: extension Binding { subscript<Subject>( dynamicMember keyPath: WritableKeyPath<Value, Subject> ) -> Binding<Subject> { Binding<Subject>( get: { self.wrappedValue[keyPath: keyPath] }, set: { self.wrappedValue[keyPath: keyPath] = $0 } ) } }
— 21:25
The only difference is that for case paths we use the extraction capabilities to implement the getter and we use the embed capabilities for the setter.
— 21:34
Our code is close to compiling. We just have one problem. When we theorized what our binding transformation view could look like, we passed the enum’s case embed function directly to the pattern argument. But that isn’t enough. We also need the extraction function, which means we need to pass a full case path to the view. Luckily for us the Case Paths library allows us to automatically derive a case path from any enum case embed function by just putting a forward slash in from of the case: IfCaseLet($item.status, pattern: /Item.Status.inStock) { quantity in … }
— 21:51
Let’s quickly implement the other case, and now what was previously 29 lines of code… IfCaseLet($item.status, pattern: /Item.Status.inStock) { quantity in Section(header: Text("In stock")) { Stepper("Quantity: \(quantity.wrappedValue)", value: quantity) Button("Mark as sold out") { self.item.status = .outOfStock(isOnBackOrder: false) } } } IfCaseLet( $item.status, pattern: /Item.Status.outOfStock ) { isOnBackOrder in Section(header: Text("Out of stock")) { Toggle("Is on back order?", isOn: isOnBackOrder) Button("Is back in stock!") { self.item.status = .inStock(quantity: 1) } } }
— 22:35
…is now only 16 lines and fits on a single screen. So we were able to cut things down by almost half and hide away repetitive code that we could have gotten wrong.
— 22:47
Even better, with this helper in place we can even leverage a new Swift 5.5 feature that allows us to pass property wrapper values to closures, which gives us simultaneous access to both the binding and the underlying wrapped value, which lets us clean things up a bit more, and makes it easy to see where we’re holding a binding: IfCaseLet($item.status, pattern: /Item.Status.inStock) { $quantity in Section(header: Text("In stock")) { Stepper("Quantity: \(quantity)", value: $quantity) Button("Mark as sold out") { self.item.status = .outOfStock(isOnBackOrder: false) } } } IfCaseLet( $item.status, pattern: /Item.Status.outOfStock ) { $isOnBackOrder in Section(header: Text("Out of stock")) { Toggle("Is on back order?", isOn: $isOnBackOrder) Button("Is back in stock!") { self.item.status = .inStock(quantity: 1) } } }
— 23:38
So this is pretty cool that we can cook up binding transformation operators that help us to model our state in the most correct way possible while still allowing us to deriving bindings easily and hand them off to UI controls.
— 23:51
One consequence of using IfCaseLet instead of switch is we have lost the guarantee of exhaustively handling each case, but we think losing exhaustivity is totally fine here, especially because introducing the helper allowed us to eliminate a lot of manual work that could easily go wrong. And a lot of the time it may even not even be desirable to have exhaustivity because you may want to render each case of an enum in a completely different area of the view.
— 24:19
Also, it is totally possible to regain exhaustivity, at runtime at least, by introducing another view helper. This is exactly what we did when we introduced a SwitchStore view helper to the Composable Architecture earlier this year , for exhaustively switching over a store’s enum state at runtime, but we’re not going to spend the time to introduce a similar helper here right now. Saving new inventory items
— 24:38
So, now this screen is fully functional, but we still don’t have any way to communicate back to the parent when we are ready add the item to our list.
— 24:54
In the item view, we would like to be able to add “Save” and “Cancel” buttons and have their actions communicate to the parent view when they are tapped.
— 25:12
The easiest way to do this would be to explore “on save” and “on cancel” hooks in the ItemView : struct ItemView: View { @State var item = Item( name: "", color: nil, status: .inStock(quantity: 1) ) let onSave: (Item) -> Void let onCancel: () -> Void … }
— 25:29
And then add toolbar items for the cancel and save that invoke those closures: .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { self.onCancel() } } ToolbarItem(placement: .primaryAction) { Button("Save") { self.onSave(self.item) } } }
— 26:21
Then when the ItemView is presented in a sheet we just need to provide the onSave and onCancel closures, as well as wrap the view in a NavigationView so that we get access to the navigation bar: .sheet(isPresented: $addItemIsPresented) { NavigationView { ItemView( onSave: { self.viewModel.add(item: $0) }, onCancel: { self.addItemIsPresented = false } ) } }
— 26:51
And we can implement the .add(item:) method easily enough: func add(item: Item) { withAnimation { _ = self.inventory.append(item) } }
— 27:05
If we run this we will see that the cancel button works just fine to dismiss the modal, but when we try to save a new item the modal is not dismissed. This is because we forgot to clean up state in the onSave handler. We have to further set addItemIsPresent to false : ItemView( onSave: { self.viewModel.add(item: $0) self.addItemIsPresented = false }, onCancel: { self.addItemIsPresented = false } )
— 27:44
This is unfortunate that we have to litter our view code with more logic, but at least when we run the preview it works as we expect. When we add an item it dismisses the modal and we see the new item in the list.
— 27:50
But, there are other problems beyond the fact that we’ve got logic creeping into our view. This new ItemView is impossible to deep link into. We can of course do it at the level of the inventory view’s preview: struct InventoryView_Previews: PreviewProvider { static var previews: some View { let keyboard = Item( name: "Keyboard", color: .blue, status: .inStock(quantity: 100) ) NavigationView { InventoryView( viewModel: .init( inventory: [ keyboard, Item( name: "Charger", color: .yellow, status: .inStock(quantity: 20) ), Item( name: "Phone", color: .green, status: .outOfStock(isOnBackOrder: true) ), Item( name: "Headphones", color: .green, status: .outOfStock(isOnBackOrder: false) ), ] // , // itemToDelete: keyboard ), addItemIsPresented: true ) } } }
— 28:17
We’ll see that it is possible to start the inventory view from this state. Although it sometimes seems to stop midway if the preview is inactive, but that appears to be due to a bug in Xcode previews. Deep-linking into the add sheet
— 28:50
But it’s not possible to launch the app into a state where we are on the inventory tab and the new item modal is open. That’s because the presentation of the modal is controlled by local @State in the inventory view, which we don’t have access to all the way back in the root content view.
— 29:16
It’s also worth noting that we can start the inventory view with the item view presented, but we can’t customize any of the data inside the modal. If we wanted to open the modal with the color automatically set to red or the status set to out of stock, we’d be out of luck. And again this is because that state is held as a local @State value, and so is hidden from us at the inventory view level.
— 29:46
Well, luckily there is a fix to these problems, and it’s something we’ve already seen a few times. We just need to extract local @State to the view model so that we can construct it from the root, which will allow us to deep link into any state.
— 30:13
We’ll start by holding an optional Item in the InventoryViewModel to represent whether or not the add item modal is opened: class InventoryViewModel: ObservableObject { @Published var inventory: IdentifiedArrayOf<Item> @Published var itemToAdd: Item? @Published var itemToDelete: Item? init( inventory: IdentifiedArrayOf<Item> = [], itemToAdd: Item? = nil, itemToDelete: Item? = nil ) { self.inventory = inventory self.itemToAdd = itemToAdd self.itemToDelete = itemToDelete } … }
— 30:30
And now we can drop the local @State from the inventory view because it is represented in the view model now: struct InventoryView: View { @ObservedObject var viewModel: InventoryViewModel // @State var addItemIsPresented = false … } Now that we have all the state in the view model we can stop doing logic in the view, where it can obscure the responsibilities of the view and is hard to test, and instead do it in the view model.
— 30:38
InventoryView is no longer compiling because it’s still trying to use the old boolean state. We can first update the toolbar buttons, where we will now invoke a view model method instead of mutating state directly: .toolbar { ToolbarItem(placement: .primaryAction) { Button("Add") { self.viewModel.addButtonTapped() } } }
— 30:51
And that method can be implemented by simply instantiating the itemToAdd state: func addButtonTapped() { self.itemToAdd = .init( name: "", color: nil, status: .inStock(quantity: 1) ) }
— 31:07
Next we have the .sheet invocation which is currently using the isPresented overload that takes a boolean binding. We now want to use the .sheet(item:) overload, which allows us to pass a binding of an optional, and when it becomes non- nil it will invoke the view closure with the unwrapped value and then display the modal. And instead of mutating state directly in the view we can instead invoke methods on the view model: .sheet(item: $viewModel.itemToAdd) { itemToAdd in NavigationView { ItemView( onSave: { item in self.viewModel.add(item: item) }, onCancel: { self.viewModel.cancelButtonTapped() } ) } }
— 31:55
And these new view model methods are straightforward to implement: func add(item: Item) { withAnimation { self.inventory.append(item) self.itemToAdd = nil } } func cancelButtonTapped() { self.itemToAdd = nil }
— 32:19
We only have one compiler error and that’s where we construct the inventory preview, because we no longer have the addItemIsPresent boolean in the view but instead have a draft item in the view model: struct InventoryView_Previews: PreviewProvider { static var previews: some View { let keyboard = Item( name: "Keyboard", color: .blue, status: .inStock(quantity: 100) ) NavigationView { InventoryView( viewModel: .init( inventory: [ keyboard, Item( name: "Charger", color: .yellow, status: .inStock(quantity: 20) ), Item( name: "Phone", color: .green, status: .outOfStock(isOnBackOrder: true) ), Item( name: "Headphones", color: .green, status: .outOfStock(isOnBackOrder: false) ), ], itemToAdd: .init( name: "", color: nil, status: .inStock(quantity: 1) ), itemToDelete: nil ) ) } } }
— 32:49
If we run this in the preview without hitting the play button we will see that the modal is displaying, though sometimes it’s only half way up.
— 32:56
If we run this in the simulator we will see that it works as expected. We are immediately brought to the inventory tab and the add item modal slides up. Deep-linking with item data
— 33:36
So it seems we have accomplished our goals. We got the logic out of the view and we can now deep link into the add item modal. However, what if we wanted to deep link into the modal with some of the fields already populated? We should be able to do that by just changing the initial data when we create the view model: itemToAdd: .init( name: "Mouse", color: .red, status: .inStock(quantity: 100) )
— 34:08
Amazingly this does immediately start the application with the modal display, but sadly the fields are not pre-filled in the way we expect.
— 34:16
This is happening because when we use the .sheet(item:) modifier to unwrap the optional draft item, we don’t do anything with that newly unwrapped value. In fact, we could even ignore it and everything would compile just fine: .sheet(item: $viewModel.itemToAdd) { _ in NavigationView { ItemView( onSave: { item in self.viewModel.add(item: item) }, onCancel: { self.viewModel.cancelButtonTapped() } ) } }
— 34:40
This means there’s no connection between what the InventoryView does and what the ItemView does. In fact, the ItemView is still even using local @State for its item: struct ItemView: View { @State var item = Item( name: "", color: nil, status: .inStock(quantity: 1) ) … }
— 34:51
One thing we could maybe do is pass along the unwrapped item to the ItemView initializer: .sheet(item: $viewModel.itemToAdd) { itemToAdd in NavigationView { ItemView( itemToAdd: itemToAdd, onSave: { item in self.viewModel.add(item: item) }, onCancel: { self.viewModel.cancelButtonTapped() } ) } }
— 35:07
If we run the app we will see this now works. The app launches and immediately a modal sheet appears with the name pre-filled to “Mouse”. We are able to deep link into an even more specific state of our application.
— 35:26
So, it seems like this does everything we want, but there are a few reasons we do not suggest this style of state management.
— 35:33
Instantiating @State like this is guided by some subtle logic that is not intuitive or well-documented. Although it seems like we are creating a whole new @State every time this view is instantiated, that is not actually the case. SwiftUI is doing some work behind the scenes to make sure the @State is only created a single time when the view is first introduced to the view hierarchy, and then tears down the @State once the view leaves the hierarchy. This is intimately related to the concepts of view lifecycle and identity, and we highly encourage our viewers watch the 2021 WWDC session titled “ Demystifying SwiftUI ” to learn more.
— 36:24
This means the InventoryViewModel has no means to update the item once the initial value is handed off to the sheet. This can be really surprising, and can prevent you from doing certain user flows.
— 36:42
For example, what if when the “Add” button was tapped we wanted to perform some super advanced AI and machine learning logic in order to pre-fill some of the item fields with what we predict the user wants. Only catch is that the logic takes a little bit of time to execute. Now, we don’t want to delay showing the modal until the logic finishes because that would make the app feel unresponsive. Instead, we’d like to pro-actively show the modal with all the fields blank, and then once the logic finishes we will update the item.
— 37:19
Let’s simulate this situation by updating the addButtonTapped so that after itemToAdd is set, which makes the modal appear immediately, we wait for 300 millisecond and then pre-fill the name of the item: func addButtonTapped() { self.itemToAdd = .init( name: "", color: nil, status: .inStock(quantity: 1) ) Task { @MainActor in try await Task.sleep(nanoseconds: 500 * NSEC_PER_MSEC) self.itemToAdd?.name = "Bluetooth Keyboard" } }
— 38:08
Sadly this does not work. When the sheet appears, the item name remains blank.
— 38:29
This is because the @State field has is given ownership of the value it was handed over the lifecycle of the view, and so later edits to itemToAdd from the InventoryViewModel have no bearing on the item inside the ItemView .
— 39:00
To really see this in action let’s add some print statements to the ItemView . First, let’s define our own initializer so we can call print inside it. struct ItemView: View { … init( item: Item = Item(name: "", color: nil, status: .inStock(quantity: 1)), onSave: @escaping (Item) -> Void, onCancel: @escaping () -> Void ) { self.item = item self.onSave = onSave self.onCancel = onCancel } … }
— 39:28
This doesn’t build because item is wrapped in a property wrapper, but what we can do is create the @State value from that item. In order to do that we need to assign the private, underscored _item , which is the true value stored in the view. The non-underscored item field is just syntactic sugar that Swift gives us by virtue of how property wrappers work. self._item = .init(wrappedValue: item)
— 39:28
Now that that’s compiling, we can insert a print statement to see when the item view is initialized, and what the item’s name is at the time. init(…) { print("ItemView.init", item.name) … } }
— 40:05
And we can also print what the item name is when the body is evaluated. var body: some View { let _ = print("ItemView.body", item.name) … }
— 40:13
Now when we run the app and tap the “Add” button we see the following printed to the console: ItemView.init ItemView.body ItemView.init Bluetooth Keyboard ItemView.body
— 40:49
This shows that the ItemView was instantiated a second time, with the “Bluetooth Keyboard” item name, yet somehow when the body property was called the item’s name went back to being an empty string. Next time: deriving optional behavior
— 40:59
So, that is one bummer to using @State in this way. The parent simply has no way of influencing the child after instantiation.
— 41:11
And there’s one last downside we want to mention, though it’s a bit of corollary to the fact that the parent cannot observe changes in the child. There is something that is not entirely ideal with how the ItemView is designed right now. Currently it holds onto these onSave and onCancel closures so that it can communicate to the parent. The only reason we did this previously is because we drove the sheet presentation off of a simple boolean binding, and the state for the item we want to save was only represented in the ItemView . The InventoryView was never exposed to that data.
— 41:46
But now we’ve refactored the sheet to be driven off of optional item state, which means the inventory view, and view model, know about the state, which gives us a chance to refactor those onSave and onCancel closures out of the ItemView .
— 42:10
The reason we’d want to do such a refactor is that putting the cancel and save hooks in the child view causes the view to be overly specified. Maybe we want to reuse that view in situations where the buttons have different labels, or different placement, or not visible at all. Supporting all of those use cases would mean we have to pass in all types of configuration data to describe exactly what kind of ItemView we want.
— 42:36
It would be far better if we just allowed whoever creates the ItemView to be responsible for attaching the action buttons, and then it would be free to use whatever labels or placement it wants.
— 42:53
Unfortunately this is not possible right now because the most up to date version of the item state lives only in the ItemView ’s @State field. The outside simply does not have access to that data.
— 43:11
So, these 3 problems are substantial enough for us to want to find another way to manage this state. In order to facilitate communication between these two views we simply need to get rid of @State and reach for a tool that allows for 2-way communication. References SwiftUI Navigation Brandon Williams & Stephen Celis • Nov 16, 2021 After 9 episodes exploring SwiftUI navigation from the ground up, we open sourced a library with all new tools for making SwiftUI navigation simpler, more ergonomic and more precise. https://github.com/pointfreeco/swiftui-navigation Collection: Derived Behavior Brandon Williams & Stephen Celis • May 17, 2021 Note The ability to break down applications into small domains that are understandable in isolation is a universal problem, and yet there is no default story for doing so in SwiftUI. We explore the problem space and solutions, in both vanilla SwiftUI and the Composable Architecture. https://www.pointfree.co/collections/case-studies/derived-behavior WWDC 2021: Demystifying SwiftUI Matt Ricketson, Luca Bernardi & Raj Ramamurthy • Jun 9, 2021 An in-depth explaining on view identity, lifetime, and more, and crucial to understanding how @State works. https://developer.apple.com/videos/play/wwdc2021/10022/ @StateObject and @ObservedObject in SwiftUI Matt Moriarity • Jul 3, 2020 An in-depth article exploring the internals of @ObservedObject and @StateObject in order to understand how they are created and torn down. https://www.mattmoriarity.com/2020-07-03-stateobject-and-observableobject-in-swiftui/ Downloads Sample code 0162-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 .