Video #217: Modern SwiftUI: Effects, Part 1
Episode: Video #217 Date: Dec 19, 2022 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep217-modern-swiftui-effects-part-1

Description
After a brief digression to explore the performance and ergonomics of identified collections, we dive into the messy world of side effects by implementing the “record meeting” screen. We’ll start with the timer, which has surprisingly nuanced logic.
Video
Cloudflare Stream video ID: bc26641bb888af32d7d1270407888500 Local file: video_217_modern-swiftui-effects-part-1.mp4 *(download with --video 217)*
References
- Discussions
- another library
- our SwiftUI Navigation library
- Getting started with Scrumdinger
- SyncUps App
- Packages authored by Point-Free
- 0217-modern-swiftui-pt4
- Brandon Williams
- Stephen Celis
- Mastodon
- GitHub
- CC BY-NC-SA 4.0
- source code
- MIT License
Transcript
— 0:05
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.
— 0:27
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.
— 0:42
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.
— 0:57
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.
— 1:20
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?
— 1:38
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.
— 1:59
So, let’s bring in that library and see how things improve. Identified arrays
— 2:04
Right now we are holding onto the standups as a simple array, even though there are many places we want to be able to access a particular standup by its id: @Published var standups: [Standup]
— 2:13
So, let’s try importing IdentifiedCollections : import IdentifiedCollections
— 2:20
And we’ll let Xcode add the library to our project and link it with our application.
— 2:29
Then we can update the state in the model to use an IdentifiedArrayOf : @Published var standups: IdentifiedArrayOf<Standup>
— 2:38
…and the initializer: init( destination: Destination? = nil, standups: IdentifiedArrayOf<Standup> = [] ) {
— 2:41
And amazingly, this seems to already compile.
— 2:45
This is because identified arrays can function just as regular arrays if you want, but they also expose a whole other set of APIs that are more performant, safer, and more ergonomic.
— 2:54
For example, to remove a standup based on an ID, we can use the remove(id:) method: // self.standups.removeAll { // $0.id == standupDetailModel.standup.id // } self.standups.remove(id: id)
— 3:09
And to update a standup we can use the subscript that takes an ID: guard let self // let index = self.standups.firstIndex( // where: { $0.id == standup.id } // ) else { return } // self.standups[index] = standup self.standups[id: standup.id] = standup
— 3:35
This code is not only performant and ergonomic, but it’s even safer.
— 3:39
Using positional indices can lead you to a situation where you accidentally update or remove the wrong standup, or even crash the app. For example, suppose we wanted to do some asynchronous work before updating the standup when editing, say making an API request. If we were to do it like so: guard let self let index = self.standups.firstIndex( where: { $0.id == standup.id } ) else { return } try await apiService.save(…) self.standups[index] = standup
— 4:00
Then we have a serious problem on our hands. Because we are capturing the index at the beginning and then performing the async work, there is a chance that by the time the API request finishes the standup has shifted in the collection or even been removed. So, if we use that positional index later we may be updating the wrong standup, or trying to update at an index that doesn’t exist, causing a crash.
— 4:19
Using IdentifiedArray solves all of these problems, and in our opinion should probably just always be used with SwiftUI applications no matter what just so that you don’t ever have to worry about this problem.
— 4:29
In fact, let’s do just that. Let’s hop over the Models.swift , and upgrade all of the bare arrays we have to be identified arrays: import IdentifiedCollections struct Standup: Identifiable, Codable { … var attendees: IdentifiedArrayOf<Attendee> = [] … var meetings: IdentifiedArrayOf<Meeting> = [] … }
— 4:49
Everything still compiles, but we are now set up to better handle adding, updating, and removing data in these data types. The record meeting view
— 4:56
OK, that was a fun little side excursion into a more modern approach for collections that plays nicely with SwiftUI, but now it’s time to move onto the boss battle of this series: the feature that actually starts and records a meeting.
— 5:07
It is by far the most complex feature, but we are going to take it one step at a time. We are going to create a new file called RecordMeeting.swift to house this code.
— 5:18
And let’s paste in some basic scaffolding. This view is going to have some heavy duty logic and behavior in it, so we are definitely going to what a dedicated model for it: import SwiftUI class RecordMeetingModel: ObservableObject { } struct RecordMeetingView: View { @ObservedObject var model: RecordMeetingModel var body: some View { Text("Record") } } struct RecordMeeting_Previews: PreviewProvider { static var previews: some View { NavigationStack { RecordMeetingView( model: RecordMeetingModel() ) } } }
— 5:25
Before even trying to implement the behavior for this feature, let’s make it so that we can actually navigate to the screen.
— 5:31
We are going to leverage all of the infrastructure we have already built by adding a new case to the Destination enum over in the detail feature: enum Destination { case alert(AlertState<AlertAction>) case edit(EditStandupModel) case meeting(Meeting) case record(RecordMeetingModel) }
— 5:49
It’s worth nothing just how amazing this little enum is becoming. We are now modeling 4 completely different, mutually exclusive destinations. We have an alert, and edit sheet, a meeting drill-down, and a record drill-down. And it’s impossible to ever have two destinations active at the same time. Just really cool stuff.
— 6:10
We can hop down to the view and override the “Start meeting” button action so that it invokes a method on the model: Button { self.model.startMeetingButtonTapped() } label: { Label("Start Meeting", systemImage: "timer") .font(.headline) .foregroundColor(.accentColor) }
— 6:11
And we can implement this method by simply hydrating the destination state so that it points to the record case: func startMeetingButtonTapped() { self.destination = .record(RecordMeetingModel()) }
— 6:33
Of course eventually we are going to need to pass some data to RecordMeetingModel , but we will cross that bridge when we come to it.
— 6:38
And finally we need to hook all of this up in the view by using our navigationDestination helper: .navigationDestination( unwrapping: self.$model.destination, case: /StandupDetailModel.Destination.record ) { $recordMeetingModel in RecordMeetingView(model: recordMeetingModel) }
— 7:00
And just like that we have the ability to drill down to the RecordMeetingView .
— 7:08
Also, we just can’t help point how cool the bottom of this view is. We have 4 completely different forms of navigation being described, but the APIs to express them all look nearly identical: .navigationDestination( unwrapping: self.$model.destination, case: /StandupDetailModel.Destination.meeting ) { $meeting in … } .navigationDestination( unwrapping: self.$model.destination, case: /StandupDetailModel.Destination.record ) { $recordMeetingModel in … } .alert( unwrapping: self.$model.destination, case: /StandupDetailModel.Destination.alert ) { action in … } .sheet( unwrapping: self.$model.destination, case: /StandupDetailModel.Destination.edit ) { $editStandupModel in … }
— 7:34
But OK, OK, enough unbridled enthusiasm for these APIs. Let’s get to the real challenge: the record meeting view.
— 7:41
There is a lot to this view. Not just behavior-wise, but also view hierarchy-wise too. Apple’s Scrumdinger code splits this view into a bunch of inert, data-only views, such as for the header, timer with speaker arc and footer. There’s nothing particularly interesting about those views, so we are just going to paste them all in: struct MeetingHeaderView: View { let secondsElapsed: Int let durationRemaining: Duration let theme: Theme var body: some View { VStack { ProgressView(value: self.progress) .progressViewStyle( MeetingProgressViewStyle(theme: self.theme) ) HStack { VStack(alignment: .leading) { Text("Seconds Elapsed") .font(.caption) Label( "\(self.secondsElapsed)", systemImage: "hourglass.bottomhalf.fill" ) } Spacer() VStack(alignment: .trailing) { Text("Seconds Remaining") .font(.caption) Label( self.durationRemaining.formatted(.units()), systemImage: "hourglass.tophalf.fill" ) .font(.body.monospacedDigit()) .labelStyle(.trailingIcon) } } } .padding([.top, .horizontal]) } private var totalDuration: Duration { .seconds(self.secondsElapsed) + self.durationRemaining } private var progress: Double { guard totalDuration > .seconds(0) else { return 0 } return Double(self.secondsElapsed) / Double(self.totalDuration.components.seconds) } } struct MeetingProgressViewStyle: ProgressViewStyle { var theme: Theme func makeBody( configuration: Configuration ) -> some View { ZStack { RoundedRectangle(cornerRadius: 10.0) .fill(theme.accentColor) .frame(height: 20.0) ProgressView(configuration) .tint(theme.mainColor) .frame(height: 12.0) .padding(.horizontal) } } } struct MeetingTimerView: View { let standup: Standup let speakerIndex: Int var body: some View { Circle() .strokeBorder(lineWidth: 24) .overlay { VStack { Text(self.currentSpeakerName) .font(.title) Text("is speaking") Image(systemName: "mic.fill") .font(.largeTitle) .padding(.top) } .foregroundStyle(self.standup.theme.accentColor) } .overlay { ForEach( Array(self.standup.attendees.enumerated()), id: \.element.id ) { index, attendee in if index < self.speakerIndex + 1 { SpeakerArc( totalSpeakers: self.standup.attendees.count, speakerIndex: index ) .rotation(Angle(degrees: -90)) .stroke( self.standup.theme.mainColor, lineWidth: 12 ) } } } .padding(.horizontal) } private var currentSpeakerName: String { guard self.speakerIndex < self.standup.attendees.count else { return "Someone" } return self.standup .attendees[self.speakerIndex].name } } struct SpeakerArc: Shape { let totalSpeakers: Int let speakerIndex: Int private var degreesPerSpeaker: Double { 360.0 / Double(totalSpeakers) } private var startAngle: Angle { Angle( degrees: degreesPerSpeaker * Double(speakerIndex) + 1.0 ) } private var endAngle: Angle { Angle( degrees: startAngle.degrees + degreesPerSpeaker - 1.0 ) } func path(in rect: CGRect) -> Path { let diameter = min( rect.size.width, rect.size.height ) - 24.0 let radius = diameter / 2.0 let center = CGPoint(x: rect.midX, y: rect.midY) return Path { path in path.addArc( center: center, radius: radius, startAngle: startAngle, endAngle: endAngle, clockwise: false ) } } } struct MeetingFooterView: View { let standup: Standup var nextButtonTapped: () -> Void let speakerIndex: Int var body: some View { VStack { HStack { Text(self.speakerText) Spacer() Button(action: self.nextButtonTapped) { Image(systemName: "forward.fill") } } } .padding([.bottom, .horizontal]) } private var speakerText: String { guard self.speakerIndex < self.standup.attendees.count - 1 else { return "No more speakers." } return """ Speaker \(self.speakerIndex + 1) \ of \(self.standup.attendees.count) """ } }
— 8:00
There’s some cool stuff in these views, but we are more concerned with the behavior of the screen than the tricks for making the visuals of the view. Apple’s tutorials on the Scrumdinger app do a great job of explaining these views, so we highly recommend everyone read up there.
— 8:13
We can take all of those views and compose then together a VStack . We aren’t going to do this from scratch because it takes a lot of time, and those details aren’t really that important, so we will paste in the view but with a bunch of “todos” that we need to fill in: var body: some View { ZStack { RoundedRectangle(cornerRadius: 16) .fill(<#TODO#>) VStack { MeetingHeaderView( secondsElapsed: <#TODO#>, durationRemaining: <#TODO#>, theme: <#TODO#> ) MeetingTimerView( standup: <#TODO#>, speakerIndex: <#TODO#> ) MeetingFooterView( standup: <#TODO#>, nextButtonTapped: { <#TODO#> }, speakerIndex: <#TODO#> ) } } .padding() .foregroundColor(<#TODO#>) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("End meeting") { <#TODO#> } } } .navigationBarBackButtonHidden(true) }
— 8:31
All of these “todos” represent the state that we need access to in order to construct the various views for the header, footer, and so on. We can fix these one-by-one by adding little bits of state to our model.
— 8:42
For example, the first todo is for the fill color of the background of the UI. This should be taken from the theme of the standup: .fill(self.model.standup.theme.mainColor)
— 8:54
This means we need to hold onto a standup in the model, and I think it doesn’t even need to be a @Published property. We don’t actually need to make any changes to it. We just need the data: class RecordMeetingModel: ObservableObject { let standup: Standup init(standup: Standup) { self.standup = standup } }
— 9:12
OK, that fixes that one todo.
— 9:14
Next we have some todos for data to pass to the header view: MeetingHeaderView( secondsElapsed: <#TODO#>, durationRemaining: <#TODO#>, theme: <#TODO#> )
— 9:18
The theme can be taken from the standup : theme: self.model.standup.theme
— 9:20
And the other two require some state to be added to the model. We need to keep track of the number of seconds that have elapsed since the meeting started, and from that we can compute how much time is remaining by subtracting from the duration of the meeting: class RecordMeetingModel: ObservableObject { @Published var secondsElapsed = 0 … var durationRemaining: Duration { self.standup.duration - .seconds(self.secondsElapsed) } }
— 9:53
And now we can fill in all the todos of the MeetingHeaderView : MeetingHeaderView( secondsElapsed: self.model.secondsElapsed, durationRemaining: self.model.durationRemaining, theme: self.model.standup.theme )
— 9:58
Next we have the timer view: MeetingTimerView( standup: <#TODO#>, speakerIndex: <#TODO#> )
— 10:01
This needs to know the current speaker because it uses that to fill in the speaker arc in the center of the screen. So, this sounds like more state we need in the model: @Published var speakerIndex = 0
— 10:14
And now we can construct the timer view: MeetingTimerView( standup: self.model.standup, speakerIndex: self.model.speakerIndex )
— 10:21
Next we have the footer view: MeetingFooterView( standup: <#TODO#>, nextButtonTapped: { <#TODO#> }, speakerIndex: <#TODO#> ) We already have the standup and speakerIndex state to pass along, but this view has something new. It requires specifying a closure that will be called when the next button is tapped. Recall that there is a button in the bottom-right of the UI that allows skipping the new speaker, and that is what this closure represents.
— 10:37
So, we need a method on the model: func nextButtonTapped() { } And now we can construct the footer view: MeetingFooterView( standup: self.model.standup, nextButtonTapped: { self.model.nextButtonTapped() }, speakerIndex: self.model.speakerIndex )
— 10:50
The next todo is for the foreground color of the UI, which can be taken from the accent color of the theme: .foregroundColor(self.model.standup.theme.accentColor)
— 10:58
Next we have a todo for the “End meeting” button action, so we need to add a method: func endMeetingButtonTapped() { } And then call that method from the view: Button("End meeting") { self.model.endMeetingButtonTapped() }
— 11:08
That fixes all of the todos, but there is one compilation error back in the detail view because now when navigating to the record screen we have to provide the standup we are dealing with: func startMeetingButtonTapped() { self.destination = .record( RecordMeetingModel(standup: self.standup) ) }
— 11:28
Now everything compiles, and the RecordMeeting preview shows what the UI looks like. None of the behavior is implemented, so everything is inert, but it’s looking pretty good.
— 11:42
I want to call out a slight change we made to this screen that differs from Apple’s Scrumdinger app. We decided to hide the default navigation back button and instead provide our own, explicit “End meeting” button. We thought that the implicit behavior of hitting “Back” causing the meeting to end was a little strange. Instead, we’d like to show an alert to have the user confirm whether or not they want to discard the meeting, save the meeting, or resume the meeting. And we will get into all of that soon enough. Timers
— 12:06
But, with the basics of the view in place, we can start adding some behavior.
— 12:25
Perhaps the simplest behavior we could add is the timer. When the view appears we should start a timer so that we can start counting up the secondsElapsed state, which should cause the UI to update.
— 12:35
In order to have this behavior in the model we need a method that acts as the “kick-off” to the view appearing.
— 12:41
SwiftUI has a view modifier specifically for this called task : .task { }
— 12:45
It even gives you an asynchronous context to operate in, and when the view goes away the context is automatically cancelled and torn down. That will be really handy for automatically stopping the timer and speech recognizer without us having to do any extra work.
— 13:01
So, let’s call an async method on the model in this closure: .task { await self.model.task() }
— 13:09
…and let’s create an async method on the model: func task() async { }
— 13:13
Now we’ve got a spot in the model to start adding asynchronous behavior.
— 13:18
One thing we could do to get a very, very basic timer in is start an infinite loop and sleep inside the loop: func task() async { do { while true { try await Task.sleep(for: .seconds(1)) self.secondsElapsed += 1 } } catch { } }
— 13:47
Already this has the preview updating, which is cool, but this is not the best way to do a timer when accuracy is necessary.
— 13:59
Sleeping is not an exact tool, and so over time small inaccuracies will accumulate and cause the true elapsed time to be quite a bit more than the state indicates.
— 14:16
And beyond the inaccuracies, this kind of code is also not a good idea because it’s reaching out to the real world in order to sleep an asynchronous context for a real amount of time. That kind of code can wreak having on tests and Xcode previews, forcing you to wait real world time just to see how your changes have affected the behavior of the feature.
— 14:33
However, we are not going to address any of that right now. We are just going to get the job done is the quickest way possible, and we will address the issues of dependencies and testing later on.
— 14:41
OK, we are finally getting some complex behavior in this feature. Let’s keep going. Let’s further layer on the logic that detects when a speaker’s time is up and progresses to the next speaker.
— 14:51
This event is completely determined by both the standup’s data and the current number of seconds elapsed. Essentially, if the number of seconds elapsed is a multiple of the number of seconds allocated for each speaker, then we know their time is up.
— 14:58
For example, if there are 6 speakers and it’s a 60 second meeting, then each speaker has 10 seconds to speak. And that means at each multiple of 10, in particular at 10, 20, 30, 40 and 50 seconds, we know we should move to the next speaker.
— 15:10
Helpfully, the Standup datatype already has a durationPerAttendee computed property, and so we can use that to see when secondsElapsed is a multiple of that value: if self.secondsElapsed.isMultiple( of: Int( self.standup.durationPerAttendee.components.seconds ) ) { }
— 15:35
We will get into this branch of the if statement at the moment we need to move on to the next speaker: self.speakerIndex += 1
— 15:40
Let’s give this a shot in the preview. It just so happens that the mock meeting we are using is for 60 seconds and there are 6 speakers. Let’s start the meeting and see that at each 10 second interval the speaker changes:
— 16:06
So that works, but we also can see that the timer keeps going up, even past the standup duration. We should be checking when we are on the last speaker so that we can end the meeting and break out of the timer: if self.speakerIndex == self.standup.attendees.count - 1 { // TODO: End meeting break } self.speakerIndex += 1
— 16:39
Now, this feature probably should not be solely responsible for ending the meeting. Instead it should probably communicate to the parent to let it know the meeting has ended, and then the parent can do the work to pop the screen off the stack, and add the meeting to the history. Also, during the walkthrough of Apple’s code we had mentioned that it would be nice if we could show an animation of the historical meeting be added to the list so that the user understands what is happening. That sounds like a responsibility for the parent.
— 17:11
We will facilitate this child-to-parent communication in the same way we did for the detail screen communicating to the list screen. A closure that can be overridden by the parent, and we will default it to be unimplemented: import XCTestDynamicOverlay … var onMeetingFinished: () -> Void = unimplemented( "RecordMeetingModel.onMeetingFinished" )
— 17:41
And then we will have the record feature tell the parent when the meeting finishes: if self.speakerIndex == self.standup.attendees.count - 1 { self.onMeetingFinished() break } self.speakerIndex += 1
— 17:46
So, that accomplishes the child speaking to the parent, but it’s predicated on the parent actually listening.
— 17:55
And typically that can be a very subtle thing. If the parent isn’t listening for that event, then the feature will just mysteriously break, and there won’t be any breadcrumbs lying around to help you figure out what went wrong.
— 18:06
And that is exactly why we default the closure to be “unimplemented.” It causes a runtime, purple warning in Xcode when run in the simulator, and it causes a test failure when run in tests. This gives you a really prominent way of being notified when you haven’t properly integrated the child and parent features.
— 18:25
Let’s show this off real quick. I want to run this feature in the simulator, but I’m really lazy and I don’t want to launch the app, add a standup, drill down to the standup, start a meeting, and then wait around for 60 seconds to see it what happens.
— 18:40
So, let’s flex our programmatic deep linking muscles. We can start the app up in a very specific state of having a standup that is very short, say 6 seconds, and being navigated into the detail view, and into the record view: var standup = Standup.mock let _ = standup.duration = .seconds(6) StandupsList( model: StandupsListModel( destination: .detail( StandupDetailModel( destination: .record( RecordMeetingModel(standup: standup) ), standup: standup ) ), standups: [standup] ) )
— 19:39
It is so incredibly powerful being able to start up the app in truly any state imaginable. It’s an absolute super power, and we hope everyone agrees.
— 19:54
OK, so let’s run this, and we will see we are immediately put into the record screen, and there’s just 6 seconds on the clock. We’ll let the time run out and we will see… well, actually we got quite a few runtime warnings: Publishing changes from background threads is not allowed; make sure to publish values from the main thread (via operators like receive(on:)) on model updates. Publishing changes from background threads is not allowed; make sure to publish values from the main thread (via operators like receive(on:)) on model updates. Publishing changes from background threads is not allowed; make sure to publish values from the main thread (via operators like receive(on:)) on model updates. Publishing changes from background threads is not allowed; make sure to publish values from the main thread (via operators like receive(on:)) on model updates. Publishing changes from background threads is not allowed; make sure to publish values from the main thread (via operators like receive(on:)) on model updates. Publishing changes from background threads is not allowed; make sure to publish values from the main thread (via operators like receive(on:)) on model updates. Unimplemented: RecordMeetingModel.onMeetingFinished
— 20:21
Looks like we are updating a published property on a background thread, with a big no-no, so let’s mark our task method as @MainActor : @MainActor func task() async { … }
— 20:36
Let’s run again, and wait for the timer to run out. We will see the following warning: Unimplemented: RecordMeetingModel.onMeetingFinished …
— 20:50
This is letting us know in a very visible manner that we have not properly integrated the recording domain into the detail domain. We need to override the onMeetingFinished closure so that we can do the clean up work for when the meeting ends.
— 21:27
We can take inspiration from the standups list feature for figuring out where to do the binding. In that feature we performed the binding when the destination changed, as well as on initialization. We will do the same here.
— 21:46
When the detail’s destination changes we will bind: @Published var destination: Destination? { didSet { self.bind() } }
— 21:54
And when initializing we will bind: init( destination: Destination? = nil, standup: Standup ) { self.destination = destination self.standup = standup self.bind() }
— 22:01
And then in the bind method we can switch over the destination and decide which destinations actually need binding. In particular, only the record case needs binding, and we just need to override the onMeetingFinished closure in order to nil out the destination: private func bind() { switch self.destination { case .alert, .edit, .meeting, .none: break case let .record(recordMeetingModel): recordMeetingModel.onMeetingFinished = { [weak self] in guard let self else { return } self.destination = nil } } }
— 23:08
We will also want to mutate the standup value here in order to append the new historical meeting, but we aren’t even transcribing audio yet, so we will worry about that later.
— 23:19
With that I would hope this works. I expect to be able to run the app, have the timer run out of time, and be automatically popped back to the detail view.
— 23:29
Well, sadly this is not the case. If we run the app we will see that after the time runs out, we seem to be left on the recording screen, but the screen goes completely blank. This is unfortunately just another one of SwiftUI’s navigation bugs.
— 23:47
For some reason, us writing nil to the destination state is enough for the record view to go blank, but somehow SwiftUI does not observe that change in the navigationDestination binding, and so SwiftUI doesn’t think the screen has been popped.
— 24:01
It’s a bummer, but luckily for us there is a really easy fix. We can force nil to be written to the binding by making use of the dismiss environment value in SwiftUI. We still want this logic to be testable though, so we are going to start by holding onto a new boolean in the record feature that determines whether or not the feature has been dismissed: class RecordMeetingModel: ObservableObject { @Published var dismiss = false … }
— 24:24
And when we detect the meeting finishes we can set this boolean to true : if self.speakerIndex == self.standup.attendees.count - 1 { self.onMeetingFinished() self.dismiss = true break }
— 24:38
And then in the view we want to observe when dismiss flips to true and invoke the dismiss action environment value. We can do this by first adding the environment value to the view: @Environment(\.dismiss) var dismiss
— 24:42
And we can listen in the view for when the model’s dismiss state flips to true , and when it does we will invoke the dismiss environment variable: .onChange(of: self.model.dismiss) { _ in self.dismiss() }
— 24:57
With just those few small changes the bug is fixed. If we run the app in the simulator, let the timer run out, then the view is correctly popped off the stack. It is a little annoying to have to work around the bug, but thankfully the workaround is quite straightforward and we can even write tests for it.
— 25:30
OK, the timer is feeling really solid. We could move onto the speech recognition part, and we will soon enough, but there’s a few more pieces of functionality we want to implement before moving onto that.
— 25:39
There’s the “End meeting” button in the top-left, which we mentioned should show an alert asking the user to confirm that they want to end the meeting. And there’s the “next” speaker button at the bottom-right. Let’s implement their logic, and then I think the only thing left for this screen is the speech recognizer.
— 25:57
Let’s start with the “End meeting” button. We’ve had some practice with alerts already, so this should be a breeze. We’ll introduce a Destination enum with a case for the alert, and that case will hold onto some AlertState : import SwiftUINavigation … enum Destination { case alert(AlertState<AlertAction>) }
— 26:17
The AlertAction should be an enum with a case for each button in the alert. We’re going to go a little above and beyond. We’re going to give 3 choices when trying to end a meeting early: they can either end it and save the meeting, end it and discard the meeting, or they can cancel out of the alert. We will model that in the following alert: enum AlertAction { case confirmSave case confirmDiscard }
— 26:55
Next we will hold onto an optional destination in the model: @Published var destination: Destination?
— 27:09
And add it to the initializer so that we can construct the model with a fully hydrated destination, enabling us to deep link into this feature: init( destination: Destination? = nil, standup: Standup ) { self.destination = destination self.standup = standup }
— 27:16
Next we can add the logic for hydrating the destination state with an alert when the “End meeting” button is pressed. In fact, we already have a method for when that button is tapped, so we can add it there: func endMeetingButtonTapped() { self.destination = .alert( AlertState( title: TextState("End meeting?"), message: TextState( """ You are ending the meeting early. \ What would you like to do? """ ), buttons: [ .default( TextState("Save and end"), action: .send(.confirmSave) ), .destructive( TextState("Discard"), action: .send(.confirmDiscard) ), .cancel(TextState("Resume")) ] ) ) }
— 27:39
Then we can implement a method that handles when one of the alert buttons is tapped: func alertButtonTapped(_ action: AlertAction) { switch action { case .confirmSave: self.onMeetingFinished() self.dismiss = true case .confirmDiscard: self.dismiss = true } } In particular, only the confirmSave case needs to notify the parent that the meeting finished. When discarding we can just dismiss without telling the parent anything.
— 29:08
Finally, in the view we can make use of the alert view modifier that ships with our SwiftUI Navigation library so that we can drive its presentation and dismissal from the destination enum: .alert( unwrapping: self.$model.destination, case: /RecordMeetingModel.Destination.alert ) { action in self.model.alertButtonTapped(action) }
— 29:40
Amazingly, that’s all it takes. With just a few lines we have added an alert to this feature, and it actually works. We can run the code in the preview to see that the alert actually shows. But then to further confirm it pops you back to the detail screen we can either run the detail preview or run in the simulator, which still has the deep linking state in place so that we are immediately brought to the record screen.
— 30:15
If we tap the “End meeting” button we see the alert, and we can choose to save or discard the meeting and we will automatically be popped back to the detail screen. It isn’t yet adding a historical meeting to the list, but OK because we haven’t even implement the speech recognition functionality yet.
— 30:38
It’s just really incredible stuff.
— 30:39
But there’s something that isn’t great about this UX of this alert. While the alert is up the timer keeps going. That is a little strange. It means the current speaker could change while we are deciding between saving, discarding or resuming the meeting, or worse, the meeting could end while the alert is up.
— 31:01
Let’s make it so that the timer pauses while the alert is presented. Since we have only a single source of truth for all alerts in the screen, this is incredibly easy to do. We can add a computed property that returns a boolean determined by whether or not an alert is presented: var isAlertOpen: Bool { switch self.destination { case .alert: return true case .none: return false } }
— 31:34
And then we can just short circuit the timer loop whenever this boolean is true: while true { try await Task.sleep(for: .seconds(1)) guard !self.isAlertOpen else { continue } … }
— 31:50
With those few changes we can now confirm in the preview that indeed when we open the alert the timer is paused, and when we resume the meeting the timer continues.
— 32:06
Again, or decision to model our domains as precisely as possible are paying dividends. If we had multiple alerts on this screen, and modeled them as multiple booleans or optionals, we would have to check if all of those properties are false or nil . And we would never know that we checked them all. Someday a new alert could be added, and this isAlertOpen property may not get updated. That would be a bug.
— 32:40
But we don’t have to worry about any of that. There’s only one single piece of state driving all navigation for this view, and so there’s only one thing to check. And best of all, all of this code is even testable. We can write a test that proves that while an alert is open, the timer is paused.
— 33:05
OK, that does it for the “End meeting” button. Let’s turn out attention to the next speaker button.
— 33:10
We already have a method on the model that is invoked when that method is called: func nextButtonTapped() { }
— 33:15
The logic to perform in here is pretty similar to what we did over in the timer. We can start by checking if we are already on the last speaker, and if so we can finish the meeting and dismiss: func nextButtonTapped() { guard self.speakerIndex < self.standup.attendees.count - 1 else { self.onMeetingFinished() self.dismiss = true return } }
— 33:42
If we are not on the last speaker, than we can advance to the next speaker, but we also have to advance the seconds elapsed: func nextButtonTapped() { … self.speakerIndex += 1 self.secondsElapsed = self.speakerIndex * Int( self.standup.durationPerAttendee.components.seconds ) }
— 34:06
With those few lines of code we now have the ability to skip speakers, and if we skip the last speaker we will be popped off the stack.
— 34:29
But, I think we can do better. From a UX perspective it’s not entirely clear that tapping the next button on the last speaker is going to end the entire meeting. We could make this a little friendly by having the user confirm that they truly do want to end the meeting. We will do that with an alert, just like we did a moment ago, but we probably don’t need the “Discard” option this time. And we will pause the timer: func nextButtonTapped() { guard self.speakerIndex < self.standup.attendees.count - 1 else { self.destination = .alert( AlertState( title: TextState("End meeting?"), message: TextState( """ You are ending the meeting early. \ What would you like to do? """ ), buttons: [ .default( TextState("Save and end"), action: .send(.confirmSave) ), .cancel(TextState("Resume")) ] ) ) return } … }
— 35:11
That’s all we need to do to support an alert for this user flow. Even though this is a completely new kind of alert we are showing, since it is all funneled through the alert case of the Destination enum, and since the view handles that case, there’s nothing else to do. Even the timer pauses when it appears.
— 35:35
Let’s give it a spin in the simulator to make sure that it works…
— 36:16
Fantastic. It’s also pretty incredible that the timer pauses while this alert is up. We didn’t even have to update our logic to accommodate that. Because both alerts are driven off the same piece of state, it just worked immediately, for free.
— 36:21
This shows how these techniques can really start to pay dividends as features become more and more complex. It may seem like overkill at the very beginning, when not much is going on. But the fact that we could show a whole new alert by just mutating a single piece of state is extremely powerful. Also, it’s pretty cool that we are now powering two different alerts from a single piece of state and a single view modifier in the view. Next time: speech recognition
— 36:43
We have now built all the behavior for the timer, and it turned out to be pretty complex! We had to create new ways for the child to communicate back to the parent, and we had to implement behavior to pause the timer whenever an alert is shown, and it was pretty cool to see how we were able to accomplish these features.
— 36:58
OK, we are finally ready to attack the speech recognition functionality.
— 37:03
We are going to do the absolute bare minimum of work to implement this behavior by cramming Speech framework code directly into our model. Longtime viewers of Point-Free will see the problems with this right away, but we want to show this approach as it is how Apple’s Scrumdinger was built, and it will make it easier to see the drawbacks…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 Identified Collections Brandon Williams & Stephen Celis • Jul 11, 2021 Identified Collections is our open source library that provides an ergonomic, performant way to manage collections of identifiable data, and fits in perfectly with SwiftUI. https://github.com/pointfreeco/swift-identified-collections 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 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 0217-modern-swiftui-pt4 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 .