EP 216 · Modern SwiftUI · Dec 12, 2022 ·Members

Video #216: Modern SwiftUI: Navigation, Part 2

smart_display

Loading stream…

Video #216: Modern SwiftUI: Navigation, Part 2

Episode: Video #216 Date: Dec 12, 2022 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep216-modern-swiftui-navigation-part-2

Episode thumbnail

Description

We add more screens and more navigation to our rewrite of Apple’s Scrumdinger, including the standup detail view, a delete confirmation alert, and we set up parent-child communication between features.

Video

Cloudflare Stream video ID: e5c1305c23d509fa1bbd460f7a524bff Local file: video_216_modern-swiftui-navigation-part-2.mp4 *(download with --video 216)*

References

Transcript

0:05

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.

0:20

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.

0:41

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.

0:56

Let’s quickly remind ourselves what that looked like over in Apple’s Scrumdinger application, and then see what it takes to rebuild. The standup detail view

1:04

If we go back to the simulator with Scrumdinger running we will see that the detail view is a mostly inert view. It just shows the data of the scrum without any editing or interactive capabilities. There are 3 main actions you can take in this screen:

1:18

You can see the history of previously recorded meetings at the bottom and even drill down to one.

1:24

You can edit the scrum, which brings up the edit view we just worked on, and that screen of course has the ability to discard any changes or commit the changes.

1:30

And finally you can start a new meeting, which does the complicated work of asking for permission, starting a timer, starting the speech recognizer, and more.

1:40

So, let’s get started. We’ll create a new file called StandupDetail.swift .

1:49

And we’ll get a stub of a view in place, but I’m not yet sure what kind of data should be held inside: import SwiftUI struct StandupDetailView: View { var body: some View { Text("Detail") } } struct StandupDetail_Previews: PreviewProvider { static var previews: some View { NavigationStack { StandupDetailView() } } }

1:54

Scrumdinger decided to pass a binding to this view, which we could do, but I’m starting to feel more and more apprehensive about using bare bindings directly in views. As we have seen it forces us to put logic directly in our view.

2:09

Now some views are simple enough that that works well. For example, the ThemePicker view took a simple binding instead of a full-blown model: struct ThemePicker: View { @Binding var selection: Theme … }

2:18

That made since because this view doesn’t have a lot of logic.

2:21

But the detail screen of a standup is a pretty important screen for the application. It looks mostly static, without much behavior, but also I think there is a missed opportunity for more behavior.

2:31

As we saw before, there are some UX quirks in this screen. First, if we had previously denied access to to speech recognition then tapping the “Start meeting” button drills you down to the record screen without any notification of the fact that the meeting will not actually be transcribed. I think it would be nice if we let the user know that we can’t record the meeting, and so they may want to enable that in settings, or they can confirm that they truly do not want to transcribe the meeting.

3:00

Another thing we mentioned before is that it would be cool if after recording a meeting you can actually see the meeting being added to the history section with an animation. That would draw your attention down there so that you can understand what just happened.

3:11

That’s a decent amount of logic, and I think it would be a bummer to cram it all in the view.

3:16

So, for this reason we are going to make the upfront decision to start with a model for this view. We just think it is going to set us up nicely for the future as we layer on more logic and behavior in this screen.

3:28

So, let’s get the basics into place: class StandupDetailModel: ObservableObject { @Published var standup: Standup init(standup: Standup) { self.standup = standup } } struct StandupDetailView: View { @ObservedObject var model: StandupDetailModel var body: some View { Text("Detail") } } struct StandupDetail_Previews: PreviewProvider { static var previews: some View { NavigationStack { StandupDetailView( model: StandupDetailModel(standup: .mock) ) } } }

3:47

Now let’s get some real UI in place for the view. Again we can basically copy-and-paste most of what Scrumdinger did in its view with just a few small tweaks. The view is not particularly complicated, it’s just a list with a bunch of sections, labels, and buttons, so I’m just going to paste it in: var body: some View { List { Section { Button { } label: { Label("Start Meeting", systemImage: "timer") .font(.headline) .foregroundColor(.accentColor) } HStack { Label("Length", systemImage: "clock") Spacer() Text(self.model.standup.duration.formatted( .units()) ) } HStack { Label("Theme", systemImage: "paintpalette") Spacer() Text(self.model.standup.theme.name) .padding(4) .foregroundColor( self.model.standup.theme.accentColor ) .background(self.model.standup.theme.mainColor) .cornerRadius(4) } } header: { Text("Standup Info") } if !self.model.standup.meetings.isEmpty { Section { ForEach(self.model.standup.meetings) { meeting in Button { } label: { HStack { Image(systemName: "calendar") Text(meeting.date, style: .date) Text(meeting.date, style: .time) } } } .onDelete { indices in } } header: { Text("Past meetings") } } Section { ForEach(self.model.standup.attendees) { attendee in Label(attendee.name, systemImage: "person") } } header: { Text("Attendees") } Section { Button("Delete") { } .foregroundColor(.red) .frame(maxWidth: .infinity) } } .navigationTitle(self.model.standup.title) .toolbar { Button("Edit") { } } }

4:05

And we can run the preview to see what it looks like.

4:09

None of the buttons work right not because their action closures are all empty, but this mostly looks the same as the corresponding screen in Scrumdinger.

4:19

We decide to make a few small UX improvements to this screen. First of all we put the history of meetings above the attendees because that seems like more important information. We also added a delete button at the bottom. Although you can delete a standup from the list view, it seems like it might be handy to be able to do it from the detail screen too. And finally we also made the history rows deletable, although that functionality is not actually hooked up yet.

4:40

Before doing that, let’s see what it takes to get the parent view to be able to drill-down to this new detail screen. Thanks to the powerful tools in our SwiftUI Navigation library this is going to be as straightforward as it was for adding the “add standup” sheet.

4:53

We can start by adding a new destination to the enum inside StandupsListModel : enum Destination { case add(EditStandupModel) case detail(StandupDetailModel) }

5:08

Already this is showing some power because we do know for certain that we would never want both the add sheet and detail drill down to be presented at the same time. By having this all bundled into a single enum we have compile time proof of this fact.

5:26

Next we can wrap the CardView of each row in a button so that when it’s tapped we can call a method on the model: Button { self.model.standupTapped(standup: standup) } label: { CardView(standup: standup) } .listRowBackground(standup.theme.mainColor)

5:47

And in that method we can hydrate the state to drive navigation to the detail screen: func standupTapped(standup: Standup) { self.destination = .detail( StandupDetailModel(standup: standup) ) }

6:06

And finally in the view we can use a new, overloaded navigationDestination API that ships with our SwiftUI Navigation library that is specifically tuned for driving drill-down navigation off of enums: .navigationDestination( unwrapping: <#Binding<Enum?>#>, case: <#CasePath<Enum, Case>#> ) { <#(Binding<Case>) -> View#> in }

6:35

It looks shockingly similar to the sheet modifier we are using just above.

6:54

First you specify a binding to the bit of optional enum state that drives navigation, and then you further specify the case of the enum you want to recognize for this navigation. Once you do that you instantly get access the data in that case, which is exactly what we need to pass along to our StandupDetailView : .navigationDestination( unwrapping: self.$model.destination, case: /StandupsListModel.Destination.detail ) { $detailModel in StandupDetailView(model: detailModel) }

7:21

With just those few changes to the model and view we have added another destination that can be navigated to, we continue to keep our domain modeled as concisely as possible, and amazingly it just works.

7:34

We can now tap on the row in the preview, and it drills down to the detail screen with all of the data displayed. We can even add a new standup, drill down to it, and we see the proper data.

7:42

So, while we probably made some choices at the beginning of developing this app that seemed strange to our viewers, it has started to pay dividends. We can now easily add new destinations to our screens without increasing the complexity of our features. All of navigation is driven off of state, and all of the features built are integrated together, which means we can easily programmatically deep link into any state and write tests on how these features interact with each other.

8:11

Let’s show off the deep linking capabilities really quickly. We can play with it directly in a preview. For example, we can start the StandupsList preview in a state where we have a single standup in the list and we are drilled down to its detail view: StandupsList( model: StandupsListModel( destination: .detail( StandupDetailModel(standup: .mock) ), standups: [ .mock ] ) )

8:38

Immediately we see the preview start in that exact state.

8:49

We can do something similar for the add flow: StandupsList( model: StandupsListModel( destination: .add(EditStandupModel(standup: .mock)), standups: [] ) )

8:55

Again the preview immediately starts in a state with the add sheet open, and some data is already pre-filled into the sheet.

9:03

If we wanted to really show off we could even open the preview with the add screen and a particular attendee’s name focused: destination: .add( EditStandupModel( focus: .attendee(Standup.mock.attendees[3].id), standup: .mock ) )

9:53

This is a huge boost to productivity if we wanted to repeatedly test a specific flow of execution, such as adding a new standup. There’s no need to do the laborious steps of tapping the “+” button, editing the standup’s properties, adding some attendees, and then hitting save. We can just simply start the preview in that exact state and immediately jump to testing the thing we actually care about.

10:20

But let’s quickly clear out this state in the preview so that we can have a clean slate for later explorations:

10:24

We can do the same with launching the application in the simulator. If we change the entry point to start drilled into the detail of a standup: StandupsList( model: StandupsListModel( destination: .detail( StandupDetailModel(standup: .mock) ), standups: [.mock] ) )

10:45

Launching the app immediately lands us in the detail view.

10:52

The only reason we have this power is because we have powered everything off of state and we have integrated child features together with @ObservedObject s rather than @StateObject s.

11:04

So, this is looking incredible, but let’s keep pushing forward. The detail screen is still inert. Let’s start adding its behavior.

11:18

Recall that there are basically 5 things you can do on this screen:

11:20

We can edit the standup.

11:24

We can start a new meeting.

11:26

We can drill down to a history item.

11:30

We can remove a history item.

11:33

And we can delete.

11:35

Let’s attack these starting with the easiest, which is the functionality for deleting a historical meeting. We already have the onDelete view modifier in place, it just doesn’t have any logic in it. We can call out to a method on the model that will perform the real logic: .onDelete { indices in self.model.deleteMeetings(atOffsets: indices) }

11:58

And that logic is quite straightforward: func deleteMeetings(atOffsets indices: IndexSet) { self.standup.meetings.remove(atOffsets: indices) }

12:15

With just that we can now delete historical meetings from the list.

12:20

Next let’s address navigation to a particular meeting. Right off the bat we are going to adopt the pattern that we know pays dividends down the road. We are going to model this destination as an enum, and then hold the navigation state as an optional: class StandupDetailModel: ObservableObject { @Published var destination: Destination? @Published var standup: Standup enum Destination { case meeting(Meeting) } init( destination: Destination? = nil, standup: Standup ) { self.destination = destination self.standup = standup } … }

13:21

This will give us the greatest flexibility in the future. As we start to add more destinations, such as edit, record and delete, we will just add cases to this enum. And it’s worth mentioning that we are putting a plain Meeting value inside the case rather than a full blown observable object because the historical meeting view doesn’t have any behavior. It just shows some data.

13:44

Next we can implement the button closure for the history row by calling out to a method on the model: Button { self.model.meetingTapped(meeting) } label: { HStack { Image(systemName: "calendar") Text(meeting.date, style: .date) Text(meeting.date, style: .time) } }

13:59

And we can implement that method by pointing the destination state to the meeting case of the Destination enum: func meetingTapped(_ meeting: Meeting) { self.destination = .meeting(meeting) }

14:11

And then finally we have the view component of this navigation. We’d like to use our powerful navigationDestination view modifier so that we can drive the drill-down from this state. To get access to that tool we need to import SwiftUINavigation: import SwiftUINavigation

14:21

And then we can make use of the navigationDestination method just like we did in the parent list view: .navigationDestination( unwrapping: self.$model.destination, case: /StandupDetailModel.Destination.meeting ) { $meeting in }

14:47

The view we want to drill down to is very simple, just showing off some data with no behavior. We can again steal this from Apple’s Scrumdinger app with just a few small modifications: import SwiftUI struct MeetingView: View { let meeting: Meeting let standup: Standup var body: some View { ScrollView { VStack(alignment: .leading) { Divider() .padding(.bottom) Text("Attendees") .font(.headline) ForEach(self.standup.attendees) { attendee in Text(attendee.name) } Text("Transcript") .font(.headline) .padding(.top) Text(self.meeting.transcript) } } .navigationTitle( Text(self.meeting.date, style: .date) ) .padding() } }

15:08

And so now we just need to construct this view in the navigationDestination trailing closure: .navigationDestination( unwrapping: self.$model.destination, case: /StandupDetailModel.Destination.meeting ) { $meeting in MeetingView( meeting: meeting, standup: self.model.standup ) }

15:17

And just like that we now have a working drill-down to the meeting view. It’s absolutely amazing how easy it is to add these new destinations.

15:28

But because we’re holding everything, we get to deep link to this very specific screen any time we want. For example, we can start the application drilled down into a specific standup, and further drilled down into a specific meeting: StandupsList( model: StandupsListModel( destination: .detail( StandupDetailModel( destination: .meeting(Standup.mock.meetings[0]) standup: .mock ) ), standups: [.mock] ) )

16:02

And the application starts up in this state, drilled two layers deep to the meeting. The delete confirmation alert

16:19

Just like that, we have a working drill-down to the meeting view. It’s absolutely amazing to see how easy it is to add these destinations to our application’s state.

16:27

But let’s keep going. The next easiest action to implement on this screen is the delete button at the bottom of the screen. When tapped we want to want to show an alert to ask the user to confirm that they want to delete the standup. And if they confirm, then we’d like to pop back to list and delete the standup from the array.

16:45

The API for dealing with alerts in SwiftUI is a little funky, and kinda deviates from all the other navigation APIs. The most general alert view modifier takes both a binding of a boolean, and an optional piece of state: .alert( <#LocalizedStringKey#>, isPresented: <#Binding<Bool>#>, presenting: <#T?#>, actions: <#(T) -> View#> )

17:11

The alert appears when the boolean binding flips to true, and then further it tries to unwrap the optional data, and if it succeeds it passes that data to the actions argument.

17:21

This is taking us back to the imprecise domain modeling we observed in Scrumdinger. There are two pieces of state to describe what could be described with a single piece of state. What happens if the boolean is true , meaning an alert should be shown, but the optional data is nil representing that there is no data to present? Or what if the boolean is false , but the data is non- nil ?

17:41

Well, it turns out that the alert doesn’t show and some warnings are printed to the console that are very easy to miss.

17:46

Luckily for us, our SwiftUI Navigation library comes with a much more concise way to describe alerts, and it looks similar to how we handled sheets and navigation destinations: .alert( title: <#(Case) -> Text#>, unwrapping: <#Binding<Enum?>#>, case: <#CasePath<Enum, Case>#>, actions: <#(Case) -> View#>, message: <#(Case) -> View#> )

18:03

You can specify a binding to an optional enum and a case path to isolate a specific case of the enum, and when the binding becomes non- nil and matches the case, the associated value will be extracted and handed to the title, actions, and message closures. That allows you to fully customize all aspects of the alert based on what data is held in the case.

18:26

For very simple alerts, this API works great, but for complex alerts we can do even better. What if there was some logic in these closures, such as conditionals to determine what kind of actions to expose, and some interpolation logic to construct the title and message. Because all of that logic is locked away in the view we would have no way to test it.

18:42

And that’s why SwiftUI Navigation comes with test-friendly data type for describing alerts that can be stored directly in the observable object model. Then, the view interprets that data type to actually show the alert.

18:53

That allows us to populate every aspect of the alert directly in the model, which means we can write nuanced tests on any logic that is layered on top.

19:00

Let’s give it a shot.

19:02

We are going to add a new case to the Destination enum to represent an alert being shown, and it will hold onto something called AlertState : enum Destination { case alert(AlertState<<#???#>>) case meeting(Meeting) }

19:17

AlertState is a datatype vended by the Swift UINavigation library, and it is generic over a type that describes all the actions that can happen inside the alert. The only action that can happen in our alert is that they confirm deletion, so let’s add an enum to represent that: enum Destination { case alert(AlertState<AlertAction>) case meeting(Meeting) } enum AlertAction { case confirmDeletion }

19:39

OK, everything is compiling, which means we can start actually implementing our logic.

19:45

In the view we can override the action closure of the “Delete” button to call a method on the model: Button("Delete") { self.model.deleteButtonTapped() }

19:56

And we can add this method to the model: func deleteButtonTapped() { }

20:02

We know that we want to point the destination state to the alert case to represent that an alert should be shown. There are multiple steps to accomplish this, and amazingly we can have a conversation with the compiler to get some assistance.

20:13

First, we can use autocomplete to select the .alert case: self.destination = .alert(<#AlertState<AlertAction>#>)

20:19

That lets us know we need to construct an AlertState . We can again use the compiler to tell us what is necessary for that, and it looks like it requires us specifying a title, message and an array of buttons: self.destination = .alert( AlertState( title: <#TextState#>, message: <#TextState?#>, buttons: <#[AlertState<AlertAction>.Button]#> ) )

20:38

The TextState type is something that ships in the SwiftUI Navigation library and it acts as an equatable, test-friendly version of the Text view in SwiftUI. It can be constructed in the exact same way Text views are constructed.

20:51

We can put in square brackets for the buttons array and use autocomplete to see what our options are there. It looks like we have the choice between “destructive”, “default” and “cancel” buttons. For our case we have one destructive button, the delete confirmation, and one cancel button: self.destination = .alert( AlertState( title: <#TextState#>, message: <#TextState?#>, buttons: [ .destructive(<#TextState#>, action: <#ButtonAction?#>), .cancel(<#TextState#>) ] ) )

21:18

And we now just have a whole bunch of placeholders we need to fill in which allows us to fully describe the exact alert we want to show to the user: self.destination = .alert( AlertState( title: TextState("Delete?"), message: TextState( """ Are you sure you want to delete this meeting? """ ), buttons: [ .destructive( TextState("Yes"), action: .send(.confirmDeletion) ), .cancel(TextState("Nevermind")) ] ) )

21:40

We have now finished describing the alert we want to show when the delete button is tapped, and because it is all held in the observable object we actually have a chance at testing it, which we will see later.

21:51

But next we can move on to the view where we can make use of the .alert view modifier that ships with our SwiftUI Navigation library. It’s not the one we looked at a moment ago, which required explicit closures for the title, message and actions. After all, we’ve already described all of that directly in the state of the model.

21:59

Instead, we can make use of a different overload that only requires us to specify where to find the AlertState in the model, as well as an action closure of where to send the action when an alert button is tapped: .alert( unwrapping: <#Binding<Enum?>#>, case: <#CasePath<Enum, AlertState<Value>>#>, action: <#(Value) -> Void#> )

22:11

We can fill in these arguments exactly as we have done time and time again while building this application: .alert( unwrapping: self.$model.destination, case: /StandupDetailModel.Destination.alert ) { action in }

22:20

The closure is called when a button is tapped in the alert, and the action passed comes from the AlertAction enum we defined above. So, let’s just send that on to the model so it can interpret it: ) { action in self.model.alertButtonTapped(action) }

22:30

And let’s add that method to the model: func alertButtonTapped(_ action: AlertAction) { }

22:42

Now, I’m not entirely sure what to do in this method yet, but before figuring that out, let’s make sure the alert actually works. We can run the preview, tap the “Delete” button and we get an alert. And tapping either button dismiss the alert.

23:10

Further, because we described this alert as just another Destination case, we are free to deep link to it. For example, the app entry point could start with the alert case hydrated: StandupsList( model: StandupsListModel( destination: .detail( StandupDetailModel( destination: .alert( <#AlertState<StandupDetailModel.AlertAction>#> ), standup: .mock ) ), standups: [.mock] ) )

23:14

Now we do need to provide some alert state here, which our model currently is responsible for populating, but maybe we can extract it to a shared helper so that we can link to that exact alert state from elsewhere, like the root. extension AlertState where Action == StandupDetailModel.AlertAction { static let delete = AlertState( title: TextState("Delete?"), message: TextState( """ Are you sure you want to delete this meeting? """ ), buttons: [ .destructive( TextState("Yes"), action: .send(.confirmDeletion) ), .cancel(TextState("Nevermind")) ] ) }

23:43

Which the model can use: self.destination = .alert(.delete)

23:49

But even cooler, we can reuse it at the app entry point to drill down to this specific alert: StandupsList( model: StandupsListModel( destination: .detail( StandupDetailModel( destination: .alert(.delete) standup: .mock ) ), standups: [.mock] ) )

23:56

And just like that, the application builds and we start, drilled down to a standup with the delete alert presented.

24:03

This is just incredible. We have a single Destination enum that is describing two very different forms of navigation. One is a drill-down and the other is an alert. And they are both mutually exclusive, meaning it makes no sense to be drilled-down into a meeting and have the alert open. In fact, that is considered user error by SwiftUI and can sometimes even lead to a crash. But we don’t have to worry about any of that since we modeled the destinations as an enum. We have compile-time proof that only one destination can be active at a time. Parent-child communication

24:31

So, now the big question is: how do we actually do the deletion? The standup detail feature has no access to the full array of standups, nor should it, and so it has no way to delete itself from that array itself. Instead we need to create some kind of mechanism to allow the child feature to communicate to the parent feature that it wants to be deleted, and let the parent feature do that work.

24:55

An easy way to do this is for the child feature to expose a callback closure that it will invoke when deletion is confirmed, and then the parent can tap into that closure to be notified. This can work great, and even mimics the delegate pattern that we are all familiar with from UIKit.

25:08

What we can try to do is to add an onConfirmDeletion closure to the StandupDetailModel that can be overridden by anyone: class StandupDetailModel: ObservableObject { var onConfirmDeletion: () -> Void … }

25:27

And then when deletion is confirmed by the alert we can invoke the closure: func alertButtonTapped(_ action: AlertAction) { switch action { case .confirmDeletion: self.onConfirmDeletion() } }

25:42

That gives any parent domain the opportunity to snoop in on the behavior of this feature. If it wants to be notified when deletion is confirmed, it can just override this little closure.

25:54

It’s worth mentioning that we could also try out this pattern in the view rather than the model. That is, the StandupDetailView exposes a onConfirmDeletion callback closure, and when the StandupsListView constructs the detail view it would override that closure to perform the deletion logic. The main reason to shy away from that pattern is that puts the integration logic between parent and child in the view layer, which means it’s completely untestable. By forcing it in the model we will have the chance to test it.

26:25

So, this all sounds great in theory, but there are ergonomics issues to think through.

26:30

First, things are not compiling because we have a new property in the model that is never initialized. We have two choices: either give it a default value, or add it to the initializer.

26:40

We can very easily give it a default: var onConfirmDeletion: () -> Void = {}

26:43

And that has immediately gotten everything compiling.

26:45

But, this can be dangerous. When constructing a StandupDetailModel we will be blissfully unaware that there is this very important step we have to take in order to properly integrate the two features together. And if we forget to do it, our feature will just be subtly broken and we will have to go digging through the code base to figure out what is going on.

27:05

Alternatively we can be very in-your-face with the new requirement by adding it to the initializer: var onConfirmDeletion: () -> Void init( destination: Destination? = nil, standup: Standup, onConfirmDeletion: @escaping () -> Void ) { self.destination = destination self.standup = standup self.onConfirmDeletion = onConfirmDeletion }

27:23

But this is far too restrictive. It is not always possible to provide this functionality at the moment of creating the model. Many times we need to be able to bind the onConfirmDeletion behavior at a later time.

27:39

In fact, we can already see this clear as day in the entry point of the application. In the entry point of the application we are trying to deep link into the detail screen, and that now means we have to provide the onConfirmDeletion closure: destination: .detail( StandupDetailModel( … standup: .mock ) ), Missing argument for parameter ‘onConfirmDeletion’ in call

27:48

But this is a completely inappropriate place to override that closure. The entry point of the application should not be implementing the logic for deleting a standup. Not only because we are in the view layer, but also it’s the entry point. Ideally there’s no logic whatsoever in the entry point, and instead it should all be pushed into the StandupListsModel , which is nice and testable.

28:13

So, while a default is very ergonomic, it is not safe because you can forget to override it. And on the other hand, forcing the closure to be provided upon initializer is very safe, but it’s not ergonomic because sometimes we need to late bind to the closure.

28:18

Well, luckily for us there’s a middle ground that delicately balances safety and ergonomics. We can make use of another library that we have open sourced called XCTest Dynamic Overlay , whose main purpose is to allow one to be able to write test helpers in application code, but also comes with a few helpers of its own.

28:52

We actually get immediate access to the library as a transitive dependency from SwiftUI Navigation , so we can already import it: import XCTestDynamicOverlay

29:00

And one of the tools this library comes with is called unimplemented . It is a function that is capable of generating a closure of nearly any signature with a very special bit of functionality. If you call that closure while running the app in the simulator or on device, you will get a purple runtime warning in Xcode. And if you call that closure in tests you will get a test failure.

29:26

This is the perfect balance between the two problems we were just considering. It provides flexibility because the closure does not need to be provided upon initializer, but if you do forget to override it you will get loud warnings or test failures.

29:43

So, let’s give it a shot.

29:44

We can default the onConfirmDeletion to be unimplemented, and we can even provide a description so that it will be easy to track down if it ever yells at us: var onConfirmDeletion: () -> Void = unimplemented("StandupDetailModel.onConfirmDeletion")

30:03

This may seem a little mind trippy, but what is happening here is the unimplemented function is returning a whole new closure that is void-to-void. It generates closures.

30:15

If we run the application and try deleting the standup from the detail view we are met with a loud warning in Xcode: Unimplemented: StandupDetailModel.onConfirmDeletion … Defined at: Standups/StandupDetailView.swift:9

30:52

And it even comes with a stack trace that shows us exactly where it happened: #0 runtimeWarn(_:file:line:) () #1 XCTFail(_:) #2 _fail(_:_:) #3 closure #1 in unimplemented<τ_0_0>(_:file:line:) #4 thunk for @escaping @callee_guaranteed @Sendable () -> (@out ()) () #5 StandupDetailModel.alertButtonTapped(_:) #6 closure #4 in StandupDetailView.body.getter #7 AlertState.Button.withAction(_:) #8 closure #1 in Button<>.init<τ_0_0>(_:action:) #9 ___lldb_unnamed_symbol215845 () #34 static App.main() () #35 static StandupsApp.$main() #36 main ()

31:12

This is pretty awesome. It provides a great balance between flexibility and safety.

31:17

But now the question is…when and where should we bind to this closure? We want to make sure to override the closure anytime the destination switches to the detail case. We can simply tap into the didSet on the destination property, and call a method that is responsible for doing all the binding: @Published var destination: Destination? { didSet { self.bind() } }

31:39

And then this bind method can switch on the destination to figure out which model is presented, and do the custom binding logic in there: private func bind() { switch self.destination { case let .detail(standupDetailModel): break case .add, .none: break } }

32:10

Currently only the detail model has bindable closures, so we can do all the work in that case: case let .detail(standupDetailModel): standupDetailModel.onConfirmDeletion = { }

32:15

In here we can do the work of finding the standup in the array and removing it. We will need to be careful to not accidentally create retain cycles though: case let .detail(standupDetailModel): standupDetailModel.onConfirmDeletion = { [weak self, id = standupDetailModel.standup.id] in guard let self else { return } withAnimation { self.standups.removeAll { $0.id == id } self.destination = nil } }

33:04

The logic for removing the standup is a little gnarly. We have to linearly scan the entire array just to find the one we want to remove by its id . There is a much better way to handle this, but it will have to wait a bit longer.

33:18

We now have the logic implemented, so maybe it all just magically works! Well, if we launch the application, and in particular remember that by default we are drilled into the detail screen, we will see that the delete functionality does not work.

33:31

However, if we back out to the list view, and then go back into the detail view we will see that suddenly it does work.

33:48

This is happening because when deep linking into a specific state of the application, the didSet callback is not executed, and hence we are not binding the model. We have to remember to do that in the initializer too: init( destination: Destination? = nil, standups: [Standup] = [] ) { self.destination = destination self.standups = standups self.bind() }

34:09

It’s precarious, but it’s just how things have to be. Luckily we will be able to get test coverage on all of this soon enough. We will be able to make sure that the parent-child communication mechanism works for both manual navigation and deep linking.

34:40

But with that done, now things work as we expect. We can deep link into the detail and delete, or we can drill down from the list view and delete. We now have an even more complex behavior in the application. We are integrating a parent and child feature together so that the two can communicate with each other.

34:57

OK, we are really chipping away at the behavior in the detail view. Just two more actions left. The “Edit” sheet for editing the standup we are viewing, and then the “Start meeting” drill down.

35:18

The “Edit” sheet is the easiest, so let’s attack it first.

35:22

It is yet another destination we can navigate to, so we should probably start by adding another case to the Destination enum: enum Destination { case alert(AlertState<AlertAction>) case edit(EditStandupModel) case meeting(Meeting) }

35:37

Then we can override the action closure for the “Edit” button to call a method on the model: Button("Edit") { self.model.editButtonTapped() }

35:47

And we can implement that method by hydrating the destination state: func editButtonTapped() { self.destination = .edit( EditStandupModel(standup: self.standup) ) }

35:59

Then in the view we can make use of the fancy sheet view modifier that comes with SwiftUINavigation , just as we did in the root list view: .sheet( unwrapping: self.$model.destination, case: /StandupDetailModel.Destination.edit ) { $editModel in NavigationStack { EditStandupView(model: editModel) .navigationTitle(self.model.standup.title) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { self.model.cancelEditButtonTapped() } } ToolbarItem(placement: .confirmationAction) { Button("Done") { self.model.doneEditingButtonTapped() } } } } }

37:07

And we need to implement these two need methods on the model: func cancelEditButtonTapped() { self.destination = nil } func doneEditingButtonTapped() { guard case let .edit(model) = self.destination else { return } self.standup = model.standup self.destination = nil }

37:39

And we can run this in the preview and everything seems to work!

37:50

But there is a problem. While the detail screen updated when we saved the changes in the edit screen, I’m not so certain that any of this logic is connected to the root list view. In fact, the Standup value that is handed off to the detail’s model is completely untethered to the Standup value sitting in the array of standups in the root model.

38:04

We can see this by removing all the deep linking test code in the entry point, running the application, drilling down to make an edit, and then popping back to the root. We will see that none of the edits were applied.

38:24

This brings us to another integration point between features. We previously saw one integration point when dealing with deletion, but now we need to be able to listen for any changes to the stand up in the detail screen and play them back to the collection of standups in the root model.

38:40

One way to do this is right in the bind method we carved out a moment ago. When we detect that the detail screen was pushed onto the screen, we can start listening for changes in the $standup published property, and play those back to the array: standupDetailModel.$standup .sink { [weak self] standup in guard let self, let index = self.standups.firstIndex( where: { $0.id == standup.id } ) else { return } self.standups[index] = standup }

39:42

Again, it is really annoying that we have to do all this index juggling, but we will be fixing that soon.

39:47

Now we do have a warning, because subscribing to a publisher produces a cancellable that we have to store somewhere. Let’s be very explicit with this by having a dedicated cancellable just for any detail screen bindings we want to do: import Combine … class StandupsListModel: ObservableObject { private var destinationCancellable: AnyCancellable? … }

40:15

Then we can do: self.destinationCancellable = standupDetailModel.$standup

40:17

Now when we run the application everything works as expected. If we drill down into a screen, make an edit, and pop back, we will instantly see those changes reflected in the list. Next time: effects

40:33

And this model binding logic is getting a little complex, but the amazing thing is that because it’s all integrated together at the model level, it’s all 100% testable. We can write tests that go through an entire user flow and prove that the features interacted with each other in the correct way. And then if we ever accidentally break the binding logic we will get instant visibility into it.

40:55

That will all come later, so let’s keep moving forward. We are down to the very last piece of functionality in the detail screen, and that’s drilling down to start a new meeting, and that feature is by far the most complex of the entire application because it involves effects.

41:10

It manages a timer for counting down the meeting time, and it manages a speech recognizer for transcribing live audio while the meeting is taking place. There’s also another effect in the app. Way back at the root we need to handle persistence of the data for saving and loading to disk.

41:25

That will be the focus of this episode, but before doing any of that, let’s have a little fun by making our domain modeling even more concise than it is now. A moment ago we came across two thorny situations that made it clear that our simple array of standups is not pulling its weight. There are many times we need to deal standup values by their ids, and forces us to scan the entire array to find a particular standup.

41:47

SwiftUI has already learned this lesson because many of its APIs require you provide Identifiable data, and in fact many of our core domain data types already conform to that protocol. So, wouldn’t it be nice if you could look up a value by its id, or remove a value by its id instead of linearly scanning the entire collection?

42:06

Well, it is possible, and it is all thanks to another library that we open sourced a year and a half ago. It’s called IdentifiedArray , and it’s a collection type that is specifically tuned for dealing with Identifiable elements. It has all of the standard collection APIs, but also comes with some enhanced APIs that allow for performant, safe and ergonomic access to elements via their id.

42:27

So, let’s bring in that library and see how things improve…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 XCTest Dynamic Overlay Brandon Williams & Stephen Celis • Mar 17, 2021 XCTest Dynamic Overlay is a library we wrote that lets you write test helpers directly in your application and library code. https://github.com/pointfreeco/xctest-dynamic-overlay 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 0216-modern-swiftui-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 .