Video #215: Modern SwiftUI: Navigation, Part 1
Episode: Video #215 Date: Dec 5, 2022 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep215-modern-swiftui-navigation-part-1

Description
We begin to layer on behavior in our rewrite of Apple’s “Scrumdinger” demo application, starting with navigation. We will do some upfront work to model it in our application state, as concisely as possible, to avoid a whole class of bugs, unlock deep linking, and enable testability.
Video
Cloudflare Stream video ID: 7642f41f2ee3181a98bfc688265df600 Local file: video_215_modern-swiftui-navigation-part-1.mp4 *(download with --video 215)*
References
- Discussions
- our SwiftUI Navigation library
- a library we built
- NonEmpty
- Getting started with Scrumdinger
- SyncUps App
- Packages authored by Point-Free
- 0215-modern-swiftui-pt2
- Brandon Williams
- Stephen Celis
- Mastodon
- GitHub
- CC BY-NC-SA 4.0
- source code
- MIT License
Transcript
— 0:05
OK, things are looking pretty good. We’ve got our first bit of visuals coming through, but so far this is just an inert view with no behavior. We just have some data in the view, and we construct the view hierarchy to display it.
— 0:16
Things start to get more interesting once we layer on behavior in an application. The first bit of behavior we will concentrate on is navigation. We need to be able to bring up sheets, drill down to screens, and show alerts. And you may think those 3 things sound quite different, but we will show that they can be modeled in the same way.
— 0:35
And as soon as we start navigating around to different screens, things start getting a lot more complicated. We need to start thinking about how to best model our domains, and we need to think about how parent and child domains can communicate with each other. Adding and editing standups
— 0:50
We are going to start with the interface that creates a new standup, which is presented in a sheet. So, let’s dig in.
— 0:54
Let’s add a toolbar button to present a sheet for adding a new standup: .toolbar { Button { self.model.addStandupButtonTapped() } label: { Image(systemName: "plus") } }
— 1:14
And we’ll create a method on the model to handle this logic: func addStandupButtonTapped() { }
— 1:18
But, what should we do in here?
— 1:20
We want to show a sheet, and ideally we model that in state so that it’s testable and programmatically deep linkable. We saw previously that it is not great to model the state with a boolean plus a piece of state, and that instead optionals would be really great: @Published var addStandup: Standup?
— 1:49
But we can go one step further to future proof this. This screen will have multiple places it can navigate to. Not only the add standup sheet, but you can also drill down to the detail of a standup, and there is also an error alert for when saving or loading data fails. And there may be more destinations in the future.
— 2:07
So we are going to take the time to properly model a Destination enum for all the different places we can navigate to: enum Destination { case add(Standup) }
— 2:26
And then in the future when we need to add another destination, it should be as simple as adding a case to the enum.
— 2:32
We will hold onto some optional Destination state in the model to represent where we are navigated to: @Published var destination: Destination? init( destination: Destination? = nil, standups: [Standup] = [] ) { self.destination = destination self.standups = standups }
— 2:38
nil represents not navigating anywhere, and then non- nil represents we are navigating to one of the destinations. We are adding this parameter to the initializer because it allows whoever is creating this model to begin with a destination hydrated, which is what unlocks deep linking capabilities.
— 3:01
Now we can implement the addStandupButtonTapped method to hydrate the destination state: func addStandupButtonTapped() { self.destination = .add(Standup(id: Standup.ID(UUID()))) }
— 3:23
There is one small thing here that may be raising a red flag in the back of your mind. We are reaching out to this global, uncontrolled dependency for generating a random UUID. That is going to make testing very difficult, and one of the main reasons to extract the view’s logic into an observable object is testability. Well, we are going to leave this for now, but we will be coming back to it later when we discuss dependencies and testing.
— 3:46
And then we want to use the sheet modifier in the view so that the sheet is presented when destination is non- nil and matches the add case.
— 4:00
However, SwiftUI does not come with the tools to make this easy. But luckily for us, our SwiftUI Navigation library does. So let’s import it: import SwiftUINavigation
— 4:12
And tell Xcode to add it to our project settings.
— 4:26
With that done we get access to an incredibly powerful sheet modifier that can drive the presentation and dismissal of a modal sheet by enum state: .sheet( unwrapping: <#Binding<Enum?>#>, case: <#CasePath<Enum, Case>#> ) { <#(Binding<Case>) -> View#> in }
— 4:46
It takes three arguments. First you supply a binding to an optional enum, where nil represents no sheet is presented, and non- nil represents the sheet is presented. We can easily derive a binding to our destination state via the observed object: .sheet( unwrapping: self.$model.destination, case: <#CasePath<Enum, Case>#> ) { <#(Binding<Case>) -> View#> in }
— 5:02
Next, you specify which case of the enum you want to drive the sheet. The enum may have many cases and there may be only a single case that actually represents a sheet shown.
— 5:14
The way this is done is via something called a “case path”. This is a library we built originally to support compositional operators in the Composable Architecture , but has since found many applications. Everything from SwiftUI navigation to parsers-printers .
— 5:28
A case path is analogous to a key path, except it is tuned specifically for abstracting over the shapes of enums, whereas key paths are more tuned for structs.
— 5:38
Key paths bundle up the abstract concepts of “getting” and “setting” a property in a single unit so that generic algorithms can be written on the shape of structs. SwiftUI uses key paths a ton. For example, the syntax we just wrote: self.$model.destination
— 5:52
…is only possible thanks to key paths. The $model variable is of type ObservedObject.Wrapper , which is a type vended by SwiftUI, and it does not have a property named destination . This should be a compiler error.
— 6:11
But, ObservedObject.Wrapper implements a special subscript called dynamicMember that takes a key path, and uses the getting and setting functionality of the key path in order to derive a binding to the destination: @propertyWrapper @frozen public struct ObservedObject<ObjectType>: DynamicProperty where ObjectType: ObservableObject { @dynamicMemberLookup @frozen public struct Wrapper { public subscript<Subject>( dynamicMember keyPath: ReferenceWritableKeyPath<ObjectType, Subject> ) -> Binding<Subject> { get } } }
— 6:35
So, where a struct gives you ways of “getting” and “setting” a property on a struct, case paths give you a way of “extracting” and “embedding” values into an enum. And our SwiftUI Navigation library comes with all types of APIs that allow one to transform bindings of enums into bindings of their cases.
— 6:55
This is one right here. In order to specify the case of the Destination enum that we want to drive navigation we have to specify a case path that isolates that case. We can do that by constructing a case path from the .add case like so: case: CasePath(StandupsListModel.Destination.add)
— 7:13
But, if you have an appetite for it, the library also ships a prefix operator to make this more succinct: case: /StandupsListModel.Destination.add
— 7:24
The forward slash operator should remind you of the backslash operator that one uses to specify key paths.
— 7:31
It’s worth noting that in a future Swift that has first class support for case paths, and maybe even dynamic member look up with case paths, this API could theoretically be shortened to: destination: self.$model.destination?.add
— 7:46
And that would be pretty amazing.
— 7:48
Now for the truly powerful part of this API. Once you specify the binding and the case to drive the sheet, you get to specify a trailing closure for the sheet’s content view, and that closure is handed a binding to the case: .sheet( unwrapping: self.$model.destination, case: /StandupsListModel.Destination.add ) { <#(Binding<Case>) -> View#> in }
— 8:17
This was the missing piece of the puzzle when we explored Apple’s code. The whole reason they needed to maintain boolean state and scratch data state is because SwiftUI’s sheet API does not hand the content closure a binding, and hence there was no way for the parent view to inspect how the child view had mutated the data.
— 8:36
Our API does just this, and we can get even use some fancy Swift features to simultaneously get access to the binding of the standup and the underlying value: ) { $standup in let _: Binding<Standup> = $standup let _: Standup = standup }
— 8:49
Let’s put in a stub of a view in, and wrap it in a NavigationStack since we know we want a title and toolbar on the sheet: ) { $standup in NavigationStack { Text("Standup") .navigationTitle("New standup") } }
— 9:01
With just that little bit of code we now have the sheet being driven off a single piece of optional state that points to an enum of all the possible destinations we can navigate to.
— 9:10
If we run in the preview and tap the “+” button we will see the sheet fly up, and we can dismiss it. Of course, there’s nothing interesting in this sheet right now, so let’s implement its real behavior.
— 9:23
We will create a new file to house this feature. We will call it EditStandup , and ideally we will be able to use the same screen for both the adding a new start up and editing an existing one.
— 9:32
And let’s paste the scaffolding of a simple view that takes a binding to a Standup so that the view is free to make any changes it wants to the value: import SwiftUI import SwiftUINavigation struct EditStandupView: View { @Binding var standup: Standup var body: some View { Form { } } } struct EditStandup_Previews: PreviewProvider { static var previews: some View { WithState(initialValue: Standup.mock) { $standup in EditStandupView(standup: $standup) } } }
— 9:42
And now we can update our sheet code to construct a EditStandupView when the sheet is presented, and thanks to the sheet API that comes with the SwiftUINavigation library, it is very easy to hand over a binding: .sheet( unwrapping: self.$model.destination, case: /StandupsListModel.Destination.add ) { $standup in NavigationStack { EditStandupView(standup: $standup) .navigationTitle("New standup") } }
— 9:53
Everything should still work exactly as it did before, but now we are set up to actually implement the behavior of the screen. We want a form in this view with various UI components, such as a text field, theme picker and duration slider, and we can hand those components a binding to our stand up so that they can mutate it directly.
— 10:19
Most of this view code can basically be copied and pasted from Apple’s Scrumdinger app with just a few small changes. There isn’t anything particularly interesting in the view since it is just a basic form with a bunch of UI components, so we are going to paste in the final code and then make some remarks about it: import SwiftUI struct EditStandupView: View { @Binding var standup: Standup var body: some View { Form { Section { TextField("Title", text: self.$standup.title) HStack { Slider( value: self.$standup.duration.seconds, in: 5...30, step: 1 ) { Text("Length") } Spacer() Text(self.standup.duration.formatted(.units())) } ThemePicker(selection: self.$standup.theme) } header: { Text("Standup Info") } Section { ForEach(self.$standup.attendees) { $attendee in TextField("Name", text: $attendee.name) } .onDelete { indices in self.standup.attendees.remove( atOffsets: indices ) if self.standup.attendees.isEmpty { self.standup.attendees.append( Attendee(id: Attendee.ID(UUID()), name: "") ) } } Button("New attendee") { self.standup.attendees.append( Attendee(id: Attendee.ID(UUID()), name: "") ) } } header: { Text("Attendees") } } .onAppear { if self.standup.attendees.isEmpty { self.standup.attendees.append( Attendee(id: Attendee.ID(UUID()), name: "") ) } } } } struct ThemePicker: View { @Binding var selection: Theme var body: some View { Picker("Theme", selection: $selection) { ForEach(Theme.allCases) { theme in ZStack { RoundedRectangle(cornerRadius: 4) .fill(theme.mainColor) Label(theme.name, systemImage: "paintpalette") .padding(4) } .foregroundColor(theme.accentColor) .fixedSize(horizontal: false, vertical: true) .tag(theme) } } } } extension Duration { fileprivate var seconds: Double { get { Double(self.components.seconds / 60) } set { self = .seconds(newValue * 60) } } }
— 10:38
There are only a few noteworthy things about this code, but first let’s run it in the preview to see that it does mostly work.
— 10:40
We see that we can edit the title of the standup, change the duration, change the theme, and add attendees. We’ve changed this UI a bit to make a little more user friendly. Now you tap the “Add attendee” button to add a row, and you are free to edit any row in the list, not just the last one. You can even swipe to delete an attendee.
— 11:04
The body of EditStandupView is pretty standard stuff. We see various UI components that are handed bindings to our standup so that they can make changes directly to the value, such as the title text field: TextField("Title", text: self.$standup.title) The slider, which does make use of a little helper to convert the Duration binding to a Double binding, which is what sliders require: Slider( value: self.$standup.duration.seconds, in: 5 ... 30, step: 1 ) { Text("Length") } There’s a theme picker, which is a little wrapper view that Scrumdinger uses to show the little picker menu: ThemePicker(selection: self.$standup.theme)
— 11:10
And finally, a ForEach that transforms a binding of the collection of attendees into a binding of each attendee so that we can hand it off to a text field: ForEach(self.$standup.attendees) { $attendee in TextField("Name", text: $attendee.name) }
— 11:23
The only other thing of note in this view is that there is a decent amount of logic in order to maintain a special invariant of the standup: we never want there to be 0 attendees. So, when we delete an attendee we check if the collection is empty so that we can add an empty entry back: .onDelete { indices in self.standup.attendees.remove(atOffsets: indices) if self.standup.attendees.isEmpty { self.standup.attendees.append( Attendee(id: Attendee.ID(UUID()), name: "") ) } }
— 11:53
And when the view first appears we check if the attendees collection is empty so that we can append one: .onAppear { if self.standup.attendees.isEmpty { self.standup.attendees.append( Attendee(id: Attendee.ID(UUID()), name: "") ) } }
— 12:00
This logic is significant enough that we probably want to some test coverage on it, but because this is all locked up in the view it is not straightforward to do so.
— 12:07
You may also be thinking this is more of a domain modeling problem. Perhaps we should use a better type than array if we truly want to guarantee that the collection is never empty. And in fact we have a library that vends such a type. It’s called NonEmpty and it is capable of modeling any kind of non-empty collection, including arrays and dictionaries. We highly recommend our viewers explore refactoring this code to use NonEmpty to see the pros and cons, but we will not do that now.
— 12:32
So, we do have a fully functioning “edit standup” feature, let’s finish integrating it into the parent feature, which is the “standups list”.
— 12:40
Currently we are presenting the EditStandupView in the sheet wrapped in a navigation stack: ) { $standup in NavigationStack { EditStandupView(standup: $standup) .navigationTitle("New standup") } }
— 12:54
This is a good start, but we need to layer on the functionality of cancelling this sheet and confirming the addition of the standup.
— 13:04
We can do this by adding a toolbar with two buttons, one for cancelling and one for confirming: .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Dismiss") { } } ToolbarItem(placement: .confirmationAction) { Button("Add") { } } }
— 13:22
We need to hook these actions up to the the StandupsListModel . The dismissal is the easiest. We will add a method that simply clears out the destination, which should cause the sheet to animate away with nothing being added to the collection of standups: func dismissAddStandupButtonTapped() { self.destination = nil } And we can hook that up in the view: Button("Dismiss") { self.model.dismissAddStandupButtonTapped() }
— 13:46
This already works in the preview. We can now tap the “Dismiss” button to close the sheet, and of course we can also still swipe down.
— 13:51
Next we add the model endpoint for confirming the addition of the standup: func confirmAddStandupButtonTapped() { }
— 13:57
Which we can call from the view: Button("Add") { self.model.confirmAddStandupButtonTapped() }
— 14:04
This method will be quite a bit more complex.
— 14:07
First of all, we know that no matter what we want the confirmation of the addition to cause the sheet to dismiss, so let’s get that out of the way right out of the gate: func confirmAddStandupButtonTapped() { defer { self.destination = nil } } This will make sure that no matter what happens in the method, the sheet will be dismissed.
— 14:19
Next we need to figure out which standup is being added. Luckily for us, the destination holds that information, and thanks to the sheet modifier handing over a binding of the actual destination data, we get instant access to the most recent changes to the standup from the edit view: guard case var .add(standup) = self.destination else { return }
— 14:49
Now I’ve decided to capture this as a var because I want to do some massaging of the data. Let’s remove any attendees that have empty names just to clean up the data: standup.attendees.removeAll { attendee in attendee.name.allSatisfy(\.isWhitespace) }
— 15:13
But the act of doing that may have left 0 attendees in the array, so let’s protect against that by making sure there is at least one attendee: if standup.attendees.isEmpty { standup.attendees.append( Attendee(id: Attendee.ID(UUID()), name: "") ) }
— 15:36
And once we get passed all of that we can finally add the standup to the model’s collection of standups: self.standups.append(standup)
— 15:44
This is a pretty significant piece of logic in here. I would feel a lot more confident about it if I could write tests for it and prove that the various edge cases are handled properly. And since we have decide to put this logic in a proper observable object rather than sprinkle it in the view, it is very easy to do. But we will look into that later.
— 16:04
With that done we can take the add feature for a spin in the preview.
— 16:07
We can tap the “+” button, make some changes to the standup, hit “Add” and we will see the sheet dismiss and the new standup is added to the list.
— 16:20
So, this is pretty incredible. We’ve got our first significant behavior in the application, and we have stayed true to our goal of modeling domains as concisely as possible. We have just one single piece of state to model whether or not the “Add” sheet is shown rather than two independent pieces of state, a boolean and a scratch piece of data for which special care had to be taken to keep them in sync. And if all of that wasn’t enough of a win, we also didn’t need to maintain an additional type just to represent the idea of a “scratch” piece of data like we did with the DailyScrum.Data type.
— 17:14
And on top of that, we went the extra mile to model the destination as an enum so that in the future as we start to add more destinations we can just add a new case. We will have compile time proof that only one single destination can be active at a time, and our state won’t balloon out of control as we try to keep a bunch of booleans in sync.
— 17:33
And because of that little bit of extra work we have also fixed the bugs that we observed in the original Scrumdinger code. In particular, the state of the view does not clear out while dismissing the sheet, and if we open the sheet, make changes, swipe the sheet away, and then re-open, the state is did not persist.
— 17:49
It’s just absolutely amazing stuff. Focus state and testing
— 17:51
It is, but we can still do better. There is significant logic in the EditStandupView and it’s starting to bug me a little bit. Eventually we are going to want to write tests on that logic, and it’s essentially impossible right now, at least without writing a full blown UI test, which is incredibly slow and buggy. An empty UI test can take upwards of a minute to run, and it would absolutely destroy our development process if we needed to wait over a minute to just test the basic logic of making sure there is at least one attendee in the attendees array.
— 18:22
So, I think we are going to want to move that logic to a model, but before doing that let’s add another feature to the screen so that we can see that we are at the breaking point of how much logic we are willing to let live in this view.
— 18:38
Right now the user experience of the add standup flow is not great. When you open the sheet you have to tap into the title field to start typing. That’s strange since everyone is going to have to do that. It would be far better if the field was focused by default.
— 18:52
And further, each time you add an attendee you have to also tap into the text field. Shouldn’t the newly added field already be focused?
— 18:59
Well, we can fix all of that quite easily thanks to @FocusState . First we can model a hashable data type that describes all the fields that can be focused. For this view it’s just the title field and a particular attendee, which we can tag with their ID: enum Field: Hashable { case attendee(Attendee.ID) case title }
— 19:18
Then we can add some focus state to the view: @FocusState var focus: Field?
— 19:26
Here nil represents that nothing is focused, and non- nil represents the field that is focused.
— 19:30
Then we can use the focused view modifier from SwiftUI to tag each focusable field with the appropriate enum case: TextField("Title", text: self.$standup.title) .focused(self.$focus, equals: .title) … TextField("Name", text: $attendee.name) .focused(self.$focus, equals: .attendee(attendee.id))
— 19:56
That’s all it takes to be able to programmatically change the focus in this view.
— 20:00
But of course now we need to actually implement the logic for focusing. We want the title to be focused by default, so we might hope we can do something like this: @FocusState var focus: Field? = .title
— 20:10
Unfortunately that doesn’t work because you are not allowed to provide a default to FocusState : Argument passed to call that takes no arguments
— 20:16
Next we could try providing a custom initializer so that we could supply a default: init(standup: Binding<Standup>, focus: Field? = .title) { self._standup = standup self.focus = focus }
— 20:33
But even that doesn’t work for some reason. We’re not sure if that’s a SwiftUI bug or not.
— 20:44
So really what we have to do is tap into onAppear so that we can set the default focus: .onAppear { … self.focus = .title }
— 20:56
Now that works as we can see in the preview.
— 21:03
Next we can beef up the logic in the “New attendee” button so that we automatically focus the last row added: Button { let attendee = Attendee(id: Attendee.ID(UUID()), name: "") self.standup.attendees.append(attendee) self.focus = .attendee(attendee.id) } label: { Text("New attendee") }
— 21:22
And if we wanted to really go above and beyond, we could even refocus when an attendee is delete so that we are focused closest to the deletion: .onDelete { indices in self.standup.attendees.remove(atOffsets: indices) if self.standup.attendees.isEmpty { self.standup.attendees.append( Attendee(id: Attendee.ID(UUID()), name: "") ) } self.focus = .attendee(self.standup.attendees[indices.first!].id) }
— 21:45
This logic is particularly gnarly because we are using an explicit subscript into the attendees array. We think this should be safe because we are always making sure the attendee array is not empty, but this is exactly the kind of logic you should have lots of test coverage on. I would want to make sure I know what happens when this code is run on an array with 1 element, as well as an array of many elements and the first or last element is removed.
— 22:11
We can’t possible test out all permutations live in the preview or simulator, especially every time we make a change to this logic. But just so that we can somewhat rest assured, let’s just check the edge case of removing an attendee when there is only one single attendee…
— 22:41
And it works!
— 22:42
But, because this logic locked away in this view it’s not possible to write an automated unit test. I’m not even 100% certain that this logic can be tested with a UI test. It would hinge on the ability to perform a swipe gesture on the row and tap the “Delete” button, which maybe is possible, but sounds like a significant amount of work for something that should be quite simple.
— 22:47
And so now I think we are finally hitting the breaking point of our view. We just have way too much logic in this view and it’s now time to move it out into its own observable object.
— 23:10
We can get a stub in place: class EditStandupModel: ObservableObject { @Published var standup: Standup init(standup: Standup) { self.standup = standup } }
— 23:15
And we can move various units of logic from the view into methods on the model.
— 23:19
For example, the deletion logic can be moved into a deleteAttendees(atOffsets:) method: func deleteAttendees(atOffsets indices: IndexSet) { self.standup.attendees.remove(atOffsets: indices) if self.standup.attendees.isEmpty { self.standup.attendees.append( Attendee(id: Attendee.ID(UUID()), name: "") ) } }
— 23:33
Now we don’t have any state for the focus, and if you are attempted to do so naively: class EditStandupModel: ObservableObject { @FocusState var focus: Field? … }
— 23:46
Then you are in for a world of hurt. Focus state cannot be moved into observable objects. It only works if it is installed directly into a view. This greatly hurts our ability to test the logic around focus, and as we saw a moment ago, we now have significant logic for focus, including explicit subscripting.
— 24:03
We will just not incorporate focus state into our model and come back to it in a moment.
— 24:09
Next we can move the “Add attendee” logic to the model: func addAttendeeButtonTapped() { let attendee = Attendee(id: Attendee.ID(UUID()), name: "") self.standup.attendees.append(attendee) }
— 24:23
Again we are leaving focus state out for now.
— 24:26
And finally, the onAppear logic can actually be moved to the initializer of the model since the model is only created a single time for the entire lifecycle of the view: init(standup: Standup) { self.standup = standup if self.standup.attendees.isEmpty { self.standup.attendees.append( Attendee(id: Attendee.ID(UUID()), name: "") ) } }
— 24:43
Next we can start holding onto the dedicate model instead of a simple binding: struct EditStandupView: View { // @Binding var standup: Standup @ObservedObject var model: EditStandupModel … }
— 24:52
Here we have decided to go with the @ObservedObject property wrapper instead of the @StateObject . We want this model to be connected to the parent view so that the parent can see all the changes we have made to the standup value. If we used a @StateObject then this view would have its own local source of truth that acts as an isolated island in the application, and we would have to cook up new mechanisms to have this view communicate with the parent. That completely destroys our ability to programmatically deep link into this screen and our ability to write integration tests that prove how this feature and the parent interact with each other.
— 25:22
So, we will not be using @StateObject .
— 25:24
We need to fix the view body by going through the model anytime we want access to the standup. And we can invoke the model’s endpoints, where the logic now lives. .onDelete { indices in self.model.deleteAttendees(atOffsets: indices) self.focus = .attendee( self.model .standup .attendees[indices.rangeView[0].startIndex] .id ) } … Button { self.model.addAttendeeButtonTapped() self.focus = .attendee( self.model.standup.attendees.last!.id ) } label: { Text("New attendee") }
— 26:05
And we need to fix the preview.
— 25:59
That makes the EditStandupView mostly functional, except for the focus logic, but let’s look at the compiler errors up in the parent. The StandupsList.swift file is no longer compiling because we can no longer create the view with a simple binding: EditStandupView(standup: $standup)
— 26:25
We need a full blown model.
— 26:27
Now, it is not correct to create a new model directly in here because this closure can be called multiple times as the view updates. We only want to create it a single time, at the moment of present the sheet.
— 26:38
Luckily for us there’s only a single piece of state that describes all of navigation in this view, and we can edit it to have it point to a full blown EditStandupModel instead of a simple, inert Standup value: enum Destination { case add(EditStandupModel) }
— 26:51
This will create a number of errors where we need to properly deal with the model, such as when setting the destination: self.destination = .add( EditStandupModel(standup: Standup(id: Standup.ID(UUID()))) )
— 27:04
…and when confirming the addition of the standup: guard case let .add(editStandupModel) = self.destination else { return } var standup = editStandupModel.standup
— 27:16
And finally, down in the view when we recognize its time to show the sheet we now have a proper model that we can pass along to the EditStandupView : ) { $editStandupModel in NavigationStack { EditStandupView(model: editStandupModel) … } }
— 27:25
With that everything works as it did before. We can tap “+”, enter some details, hit “Add”, and the new standup is added to the end of the list.
— 27:42
Even the focus logic works, but sadly all of that logic is still relegated to the view, and hence untestable. We can fix that using another powerful tool in our SwiftUI Navigation library .
— 27:51
While it is not possible to hold onto @FocusState directly in the model, we can hold onto a published property: class EditStandupModel: ObservableObject { @Published var focus: Field? @Published var standup: Standup enum Field: Hashable { case attendee(Attendee.ID) case title } init( focus: Field? = .title, standup: Standup ) { self.focus = focus self.standup = standup … } … }
— 28:12
This allows us to implement all of our focus logic directly in the model: func deleteAttendees(atOffsets indices: IndexSet) { … self.focus = .attendee( self.standup.attendees[indices.first!].id ) } … func addAttendeeButtonTapped() { … self.focus = .attendee(attendee.id) }
— 28:16
And we can remove all of that logic from the view.
— 28:24
Now it seems that we have inadvertently created two sources of truth. There is the focus property in the model, and there is a separate focus property on the view, and there’s nothing connecting them together.
— 28:36
Well, there is where a special view modifier from SwiftUINavigation comes into play. We can bind these two sources of truth so that they become a single source of truth: .bind(self.$model.focus, to: self.$focus)
— 28:56
This will make sure that any changes to the model’s focus is automatically played to the view’s focus, and vice versa. Further, the initial value of the view’s focus will be set from the model, so we can even get rid of the onAppear logic.
— 29:10
With those changes we now have just one single place to worry about focus logic, and it is directly in our model. In fact, if we look around the view we will see that there is literally zero logic. There isn’t one single if statement, no for loops or assignments. There’s nothing! It’s just us calling out to the model’s endpoints.
— 29:32
This should make it very easy to start writing tests. We’re going to go super deep into tests later in this series, but it might be fun to dabble in tests right now just to show off some of the powers we have.
— 29:41
We’ll start up a new test file called EditStandupTests.swift , and we’ll get a stub of a test in place for one of the most dangerous code paths we have, that of deleting an item: import XCTest @testable import Standups class EditStandupTests: XCTestCase { func testDeletion() { } }
— 29:52
We can construct a model that starts with a standup that has a few attendees: let model = EditStandupModel( standup: Standup( id: Standup.ID(UUID()), attendees: [ Attendee(id: Attendee.ID(UUID()), name: "Blob"), Attendee(id: Attendee.ID(UUID()), name: "Blob Jr"), ] ) )
— 30:00
Then we can emulate the user removing “Blob Jr” from the list of attendees: model.deleteAttendees(atOffsets: [1])
— 30:08
And then we can assert on what we expect to change. I expect the collection of attendees to go down to just one attendee, which is Blob: XCTAssertEqual(model.standup.attendees.count, 1) XCTAssertEqual(model.standup.attendees[0].name, "Blob")
— 30:32
If we run the test we see that… well, we have a crash: Thread 1: Fatal error: Index out of range
— 30:38
I guess it’s a good thing we are writing tests for this! It seems our logic for refocusing is still not quite right, even though when I ran in the simulator and preview everything seemed fine for a few various scenarios. Looks like there’s an edge case specifically with removing the last element, but only when there is more than one element in the array. I guess we didn’t test out that specific scenario in the preview, nor should we really be expected to. We should be able to write automated tests for these nuanced edge cases.
— 31:03
The fix is to be a lot more careful with our index juggling: let index = min( indices.first!, self.standup.attendees.count - 1 ) self.focus = .attendee(self.standup.attendees[index].id)
— 31:27
Now the test passes.
— 31:33
But let’s get more test coverage here. Because after the attendee is deleted, I further expect the focus to change to Blob’s text field: XCTAssertEqual( model.focus, .attendee(model.standup.attendees[0].id) )
— 31:56
And it still passes!
— 31:58
This is our first passing test, and honestly it is testing something quite complicated. We are proving that when the user deletes the last attendee in the list that not only is that element removed from the array, but further the focus is reset to the next row. As long as we can trust that SwiftUI will observe these state changes and do the right thing we can have a lot of confidence that the screen will work as we expect.
— 32:17
Let’s write a test for the last bit of functionality in this feature: adding an attendee. This is a little bit simpler than deleting. I expect to be able to start out with an empty standup, tap the “Add attendee” button, and not only should the attendees array have a single element, but the focus should change to that attendee: func testAdd() { let model = EditStandupModel( standup: Standup(id: Standup.ID(UUID())) ) XCTAssertEqual(model.standup.attendees.count, 0) model.addAttendeeButtonTapped() XCTAssertEqual(model.standup.attendees.count, 1) XCTAssertEqual( model.focus, .attendee(model.standup.attendees[0].id) ) }
— 32:49
Well, surprisingly this test fails. In fact, every assertion failed: testAdd(): XCTAssertEqual failed: (“1”) is not equal to (“0”) testAdd(): XCTAssertEqual failed: (“2”) is not equal to (“1”) testAdd(): XCTAssertEqual failed: (“Optional(Standups.EditStandupModel.Field.attendee(C3C83D7B-1A67-445C-8380-915D154FDD7E))”) is not equal to (“Optional(Standups.EditStandupModel.Field.attendee(681BCF27-32B3-40EB-BC65-668005802B46))”)
— 32:57
This is happening because secretly there is a little bit of logic squirreled away in the model’s initializer. Remember that it wants to force there to be at least one attendee, and so if it sees an empty array it goes ahead and adds a blank attendee.
— 33:09
This is forcing us to get test coverage on that little bit of logic. We can update the test easily: XCTAssertEqual(model.standup.attendees.count, 1) model.addAttendeeButtonTapped() XCTAssertEqual(model.standup.attendees.count, 2) XCTAssertEqual( model.focus, .attendee(model.standup.attendees[1].id) )
— 33:27
And while we’re here we might as well get test coverage on the initial focus of the screen too: XCTAssertEqual(model.standup.attendees.count, 1) XCTAssertEqual(model.focus, .title) model.addAttendeeButtonTapped() XCTAssertEqual(model.standup.attendees.count, 2) XCTAssertEqual( model.focus, .attendee(model.standup.attendees[1].id) )
— 33:41
So, this is absolutely incredible. Because we have decided to extract our feature’s logic into a proper observable object, we instantly get the ability to write tests. And these tests are covering very complicated logic, that we actually got wrong at first, and is even proving how focus moves around the screen.
— 33:56
So, while using a plain binding in the EditStandupView was the easiest way to get started with the feature, it definitely is not the most future proof way to structure things. Using bindings directly in the view means that all of your feature’s logic is going to have to be in the view. It will be very difficult to get test coverage on that logic, so as the feature gets more complex you may want to upgrade the binding to a proper model. Next time: standup details
— 34:17
And with that the EditStandupView is feature complete and fully tested. It’s time to move onto the next piece of functionality in the application: the ability to drill down to a detail view so that you can make edits to an existing meeting.
— 34:31
Let’s quickly remind ourselves what that looked like over in Apple’s Scrumdinger application, and then see what it takes to rebuild…next time! References Getting started with Scrumdinger Apple Learn the essentials of iOS app development by building a fully functional app using SwiftUI. https://developer.apple.com/tutorials/app-dev-training/getting-started-with-scrumdinger SyncUps App Brandon Williams & Stephen Celis A rebuild of Apple’s “Scrumdinger” application that demosntrates how to build a complex, real world application that deals with many forms of navigation (e.g., sheets, drill-downs, alerts), many side effects (timers, speech recognizer, data persistence), and do so in a way that is testable and modular. https://github.com/pointfreeco/syncups SwiftUI Navigation Brandon Williams & Stephen Celis • Sep 7, 2021 A library we open sourced. Tools for making SwiftUI navigation simpler, more ergonomic and more precise. https://github.com/pointfreeco/swiftui-navigation NonEmpty Brandon Williams & Stephen Celis • Jul 25, 2018 NonEmpty is one of our open source projects for expressing a type safe, compiler proven non-empty collection of values. https://github.com/pointfreeco/swift-nonempty Packages authored by Point-Free Swift Package Index These packages are available as a package collection, usable in Xcode 13 or the Swift Package Manager 5.5. https://swiftpackageindex.com/pointfreeco Downloads Sample code 0215-modern-swiftui-pt2 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 .