Modern SwiftUI: Introduction

Episode #214 • Nov 28, 2022 • Free Episode

What goes into building a SwiftUI application with best, modern practices? We’ll take a look at Apple’s “Scrumdinger” sample code, a decently complex app that tackles many real world problems, get familiar with how it’s built, and then rewrite it!

This episode is free for everyone.

Subscribe to Point-Free

Access all past and future episodes when you become a subscriber.

See plans and pricing

Already a subscriber? Log in

Introduction

In the past 3 episodes we explored some of SwiftUI’s newest tools for navigation, in particular NavigationStack and the navigationDestination view modifier. Those 3 episodes formed the capstone of a 12-part series of episodes where we cover SwiftUI navigation from first principles. In those episodes we were able to unify pretty much all forms of navigation into one single style of API.

It was all pretty incredible, and I’m betting that a lot of our viewers are really itching for us to move onto navigation for the Composable Architecture. It’s been a long time coming, and we have some amazing things to share for that soon, but it’s not time for that just yet.

We want to spend a little more time with vanilla SwiftUI because we feel there aren’t enough examples out there of applications written with best, modern practices. By this we mean an application that is decently complex in order to show off real world problems, built in a way that can be tested, built in a way that is modular, and using all of Swift’s powerful domain modeling tools.

Now of course we feel that the Composable Architecture is one of the best ways to create such applications, but we also know that many people do not want to use our library or possibly just can’t. So, we still think it’s worthwhile exploring how modern SwiftUI applications can be built.

To demonstrate all of this we are going to rebuild an application that Apple released a few years ago called “Scrumdinger.” It doesn’t get as much attention as the WWDC samples, such as the Fruta or Food Truck demo apps, and that’s a shame because it’s a fun little application with quite a bit of complex logic in it. We think it does a much better job of showing the problems that need to be solved in a real world app than Fruta or Food Truck.

After giving a quick demo of the application we are going to rebuild it from scratch using modern, best practices for building SwiftUI applications. Along the way we will find a number of deficiencies in Apple’s code that we will want to fix, but we are in no way judging the code. Apple’s code serves a very specific purpose, which is to introduce SwiftUI concepts to hundreds of thousands, if not millions, of developers. Given that lofty goal it is no surprise that they build their sample code in the most barebones way possible. But we still want to be able to show how to build applications in a way that scales with team size and application complexity.

Let’s begin.

Tour of the app

The Scrumdinger application is actually a full blown tutorial that Apple released. We can open it in Safari…

From the first page of the tutorial we learn:

This module guides you through the development of Scrumdinger, an iOS app that helps users manage their daily scrums.

And if we look at the table of contents we will see that this demo encompasses an impressive number of topics. It’s got views, navigation, state management, persistence, drawing and even recording audio.

We have the final project already downloaded and opened. Let’s run it in the simulator to see what all it can do.

The app launches into an empty list view. Right now there is only one action we can take, and that’s to tap the “+” button for adding a new daily scrum meeting.

Tapping the “+” button brings up a sheet with a form that allows us to edit the details of the meeting. We can set a title, duration, theme, and we can add attendees by name. Further we have the choice of dismissing the sheet without adding the meeting, or we can tap “Add” to actually add it.

Let’s go ahead and a new meeting for “Point-Free”, and we will add “Brandon”, “Stephen” and “Blob” to the attendees list.

Now one weird thing about this attendee interface is that you have to actually hit the blue “+” button to add the attendee. This means if we tapped “Add” in the top-right, Blob wouldn’t actually be an attendee. Also the previous attendees aren’t editable at all. These are minor user experience annoyances, and we’re going to take the time to fix some of them as we recreate this application.

Now let’s tap the “Add” button, and we will see the sheet dismiss and the new meeting was added to the of the list.

Now, I don’t know if you saw it, but there was a little bit of glitchy behavior when the sheet was animating away. If we bring it back up, change some of the fields, and turn on slow animations, we will see that while it is animating away the form fields reset back to their defaults.

Further, if we bring up the sheet again, make some changes to fields, and swipe down on the sheet to dismiss, then we will see that bringing the sheet up again shows all that data still there. It wasn’t cleared out, which seems like a bug.

This is all happening due to a domain modeling deficiency, and goes to show why it can be important to model your domain as concisely as possible.

The only other action we can take on this screen is to tap a row to drill-down to the meeting detail. So, let’s go to the “Point-Free” meeting.

This screen mostly just shows a summary of the details of the meeting. We see the title, length, theme and list of attendees. There is also something called “History” and we will see what this is in a minute.

There are a few actions we can take on the screen.

First, we can hit “Edit” to bring up a sheet with a form that allows us to edit any of the details of the meeting. One interesting thing about this modal is that we can make changes to the data and then hit cancel, and we will see those changes were not saved. This must mean this screen manages a bit of scratch data so that it can make changes without changing the original source of truth.

Another action we can take on the detail screen is to start the meeting. That will drill down to a new screen with a timer going and letting you know whose turn it is to give their update. The screen will even record the audio from your meeting and transcribe it so that it can be referenced at a later time. That’s what the history section down below is all about.

Let’s try it out. I’ll tap “Start Meeting”…

Huh ok, that’s weird.

We drilled down, immediately got a speech recognition authorization alert, but then we could see in the background that the screen was popped back to the detail view. This seems to be a bug with system alerts showing on top of fire-and-forget navigation. It’s very strange, but is also yet another reason why it’s best to model navigation as state.

Let’s go ahead and grant access to speech recognition.

And we are immediately prompted with another alert, this time to access the microphone. So will go ahead and authorize that too.

OK, we got past the authorization alerts. It’s still a little weird we got bumped back to this screen. Another weird thing is that the history section now has a row. It seems that when we got popped back to the detail view the application thought we finished a meeting. So that’s another bug.

But let’s keep going. Let’s try to start another meeting.

OK now when we drill down we get to the recording screen. There’s a few things to note here. First, there is a timer going that lets you know how much time is left in the meeting. Also the current speaker is prominently shown in the middle. Further, my audio is actually being live transcribed in the background right now.

Each speaker get one third of 2 minutes for their update, which is about 40 seconds. So once the 40 seconds elapses we should her a ding, and then we see that “Stephen” is now the speaker.

There are a few actions we can take on this screen. We can tap the arrow button in the bottom right to skip a speaker. If I do that we will now see that “Blob” is the current speaker.

Next we can just wait until the timer runs out to finish the meeting. Let’s do that.

OK we’ve got just a few seconds left, and… well. We heard a ding sound, it now says “Someone” is speaking, and the timer is still going up.

This looks like another bug in the application. I would expect that when the timer finished we would be popped back to the detail screen and the meeting would be added to our history.

Well, we can do that manually by tapping the back button in the top-left.

OK, and we do indeed see a new meeting as been added to the history. I think one improvement to the flow we just witnessed is if we could have actually seen the new meeting being added to the history section with an animation. That would draw our attention to that section so that we know what it represents. Maybe that’s something we can look into when we rebuild the application.

Let’s tap the history row to drill down to see what’s inside.

Well, not much. It looks like the speech recognition didn’t actually work. Now, this may not actually be the fault of the code in Scrumdinger. We have found the Speech framework can be pretty finicky in the simulator, so maybe this just needs to run on a real device to really see this functionality.

So, we’ve now created a daily scrum and recorded a meeting in the scrum. Let’s close the app in the simulator and then relaunch the application.

We will see that our scrum is already in the list, so the app must have some mechanism for persisting data to disk and loading it on launch. We can even drill down to the scrum to see that the history was also persisted.

Tour of the codebase

OK, that is the entirety of the app. It’s only 4 screens: the root list, the add/edit screen, the detail screen, and the recording screen. So, it seems somewhat simple, but there are some really interesting interactions happening.

  • There’s the interaction where the edit screens works on a scratch piece of data and the changes are only committed if the “Save” button is tapped.
  • There is complex logic around navigation, such as when the meeting timer ends we should pop the screen off the stack.
  • The application uses a complex Apple framework in order to live transcribe audio to text.
  • And finally, the application persists the data to disk.

This is just a really fun application, and I think perfectly demonstrates a lot of real world problems that one must solve when building applications.

Now that we know what the application does, let’s take a look at how it was built. This will help us compare our approach to Apple’s approach.

Now it’s important for us all to keep in mind that the code in this project is not necessarily meant to be taken as holy scripture. We understand that Apple’s goals with these sample demos isn’t to show how to build the perfect, production-worthy application, but instead provide code to show off SwiftUI’s feature and be accessible to hundreds of thousands of developers.

So, we are not judging the quality of the code or trying to poke fun at it. But at the same time we do want to show an alternative way of building SwiftUI applications because people will look to Apple’s code for guidance on how to solve problems.

Let’s start at the entry point of the application so that we can understand how everything gets kicked off.

The root App conformance is powered by a @StateObject called ScrumStore, as well as some kind of optional error type:

struct ScrumdingerApp: App {
    @StateObject private var store = ScrumStore()
    @State private var errorWrapper: ErrorWrapper?
    …
}

The ScrumStore sounds fancy, but there isn’t much to it. It just consists of a single @Published property holding an array of scrums:

class ScrumStore: ObservableObject {
    @Published var scrums: [DailyScrum] = []
    …
}

…and a few static methods that are responsible for saving and loading data from disk.

The app’s body consists of a navigation view with a ScrumsView at its root:

NavigationView {
    ScrumsView(scrums: $store.scrums) {
        …
    }
}

The ScrumsView is what is responsible for showing the list of scrums.

The trailing closure of ScrumsView is not a view builder, but rather an action closure that is invoked whenever the ScrumsView wants its data saved. This is part of the persistence logic. Work is done inside that closure to try to save the data, and that just calls out to the some static method on the observable object. Also, if an error occurs the error state is hydrated to show a sheet, which we will see below.

Next, there’s a .task view modifier on the navigation view, which gets executed whenever the view appears. But since the navigation view is the root of the entire application, it will only be called a single time.

This is why it’s an appropriate place to load the previously saved data in order to populate the scrums array in the ScrumStore. And again, if an error occurs the error state will be populated:

.task {
    do {
        store.scrums = try await ScrumStore.load()
    } catch {
        errorWrapper = ErrorWrapper(
            error: error,
            guidance: """
                Scrumdinger will load sample data and \
                continue.
                """
        )
    }
}

And then the last thing in the view is the code for showing the sheet when an error occurs:

.sheet(item: $errorWrapper, onDismiss: {
    store.scrums = DailyScrum.sampleData
}) { wrapper in
    ErrorView(errorWrapper: wrapper)
}

That’s all there is to the entry point, which must mean the real meat of the application is in the ScrumsView. So let’s hop over to that file.

At the top of the file we can see all the data the view needs to do its job:

struct ScrumsView: View {
    @Binding var scrums: [DailyScrum]
    @Environment(\.scenePhase) private var scenePhase
    @State private var isPresentingNewScrumView = false
    @State private var newScrumData = DailyScrum.Data()
    let saveAction: ()->Void

    …
}

It requires a binding to an entire array of scrum meetings, which is passed in from the parent view, which is the entry point of the application.

It also needs access to the scenePhase environment variable, which it uses to be notified when the application is backgrounded so that it can persist the application’s data.

Next there are two private, local pieces of state. A boolean that controls whether or not the new scrum view is presented, and then another piece of state that represents the scratch piece of state that is operated on when the sheet is up.

As we’ve discussed a few times in our navigation series, it is not ideal to model navigation state as a boolean plus a piece of state. It is strange for the boolean to be false, meaning the sheet is not presented, while also holding onto a piece of data. What does that data represent at that moment? Care needs to be taken to properly clear out that data when the sheet is dismissed, and that kind of uncertainty starts to leak into every part of your application.

Now you might wonder, why wasn’t the new scrum data modeled as an optional like this:

@State private var newScrumData: DailyScrum.Data?

Then nil would represent the sheet is not presented, and a non-nil value would represent it is presented. And SwiftUI even ships with a sheet API that deals with optional data.

Well, the reason is because that sheet API does not allow presenting a sheet with a binding, which would allow the changes to the data to be observed by this view. Instead it is just handed an inert value with no connection to the parent.

That is the sole reason this domain is modeled as a boolean plus some state. It is specifically so that the parent view can have access to the binding:

@State private var isPresentingNewScrumView = false
@State private var newScrumData = DailyScrum.Data()

Further, this deficiency in the domain modeling has also forced the maintenance of a dedicated DailyScrum.Data type, which differs from DailyScrum. This Data type represents a scratch piece of scrum data that is suitable for editing in the sheet, and then additional code is maintained for converting back and forth between DailyScrum and DailyScrum.Data.

Simply put, this is code that just should not need to be maintained, and we will show how the APIs in our SwiftUI Navigation library improve this greatly.

It’s also worth noting that since all of this state is modeled locally with @State we will have no way to deep link into the “add scrum” screen. That ability is completely hidden from us since @State creates its own local source of truth, and cannot be influenced from the outside.

Next we have the body of the view, and it begins with a List and a ForEach to show a row for each meeting in the scrums array:

List {
    ForEach($scrums) { $scrum in
        NavigationLink(
            destination: DetailView(scrum: $scrum)
        ) {
            CardView(scrum: scrum)
        }
        .listRowBackground(scrum.theme.mainColor)
    }
}

This is using the fancy initializer on ForEach that can transform a binding to a collection into a binding to an individual element in that collection:

ForEach($scrums) { $scrum in
  …
}

In that trailing closure, $scrum is a binding to a single scrum inside the scrums array, which is pretty powerful.

Now, one strange thing in this code is that it is using the fire-and-forget style of navigation link:

NavigationLink(destination: DetailView(scrum: $scrum)) {
  …
}

This navigation link is not state-driven at all, which means we will never be able to programmatically deep link directly into a detail view for a scrum, whether that be from a URL or a push notification. The only way to get to the detail view of a scrum is for the user to literally tap on the row.

The reason the code was structured this way is probably just to keep the application simple, but once we start rebuilding the application we will see that we don’t have to sacrifice powerful features such as deep linking just to keep things simple. We can support deep linking with very little work. If we ever need to support deep linking to the detail screen from the user tapping on a URL or opening a push notification, we’ll be out of luck.

The next interesting thing is the toolbar:

.toolbar {
    Button(action: {
        isPresentingNewScrumView = true
    }) {
        Image(systemName: "plus")
    }
    .accessibilityLabel("New Scrum")
}

It has a button in order to flip the boolean that drives the sheet to true. Again, it would be far better if this was hydrating an optional piece of state instead.

Next we have the code for presenting the sheet when the the boolean flips to true:

.sheet(isPresented: $isPresentingNewScrumView) {
    NavigationView {
        DetailEditView(data: $newScrumData)
            .toolbar {
                ToolbarItem(
                    placement: .cancellationAction
                ) {
                    Button("Dismiss") {
                        isPresentingNewScrumView = false
                        newScrumData = DailyScrum.Data()
                    }
                }
                ToolbarItem(
                    placement: .confirmationAction
                ) {
                    Button("Add") {
                        let newScrum = DailyScrum(
                            data: newScrumData
                        )
                        scrums.append(newScrum)
                        isPresentingNewScrumView = false
                        newScrumData = DailyScrum.Data()
                    }
                }
            }
    }
}

And now we can see plain as day why the domain was modeled the way it was. When the sheet is presented it is done so with a tool bar that has the “Dismiss” and “Add” buttons. The “Add” button needs to get access to the freshest meeting with all the edits.

If instead we held onto the scrum data as an optional:

@State private var newScrumData: DailyScrum.Data?

And used the sheet(item:) view modifier:

.sheet(item: $newScrumData) { newScrumData in
    …
}

Then all we get access to is a plain, inert value newScrumData, and further we get access only at the moment it became non-nil. That value will be handed to the DetailEditView, and it will encapsulate the logic for mutating the scrum, but those mutations will be completely hidden from the ScrumsView:

.toolbar {
    ToolbarItem(placement: .confirmationAction) {
        Button("Add") {
            // How do we get the item
            // from the DetailEditView???
        }
    }

This is exactly why the domain holds onto state for both a boolean and the scratch piece of scrum data. It’s so that the boolean can drive the presentation of the sheet while still handing the DetailEditView a binding to the data so that we can observe changes to it at this level.

This is a bummer. The deficiencies in SwiftUI’s APIs are forcing us to model our domain in less than ideal ways, and this is the exact problem our SwiftUI Navigation library aims to solve. And these deficiencies can manifest themselves as bugs really easily.

For example, take a look at the actions for the “Dismiss” and “Add” buttons side-by-side:

Button("Dismiss") {
    isPresentingNewScrumView = false
    newScrumData = DailyScrum.Data()
}
…
Button("Add") {
    let newScrum = DailyScrum(data: newScrumData)
    scrums.append(newScrum)
    isPresentingNewScrumView = false
    newScrumData = DailyScrum.Data()
}

Notice that there is some duplication of code, and that it has to be careful to manage the two pieces of state simultaneously. It first needs to set the boolean to false, to make the sheet animate down, and then it further has to reset the newScrumData.

If we forgot to reset that data:

// newScrumData = DailyScrum.Data()

…then we would have a serious problem on our hands.

It would mean that when we add a new scrum to the list and then try to add another, the previous scrum’s data is prefilled.

That would create a really annoying experience for the user if every time they wanted to add a scrum they would have to clear out all the previous data.

This imprecision in the domain modeling is exactly why we observed the two bugs we did earlier. For example, the fact that the screen clears its data while the sheet is animating down is because of these lines:

isPresentingNewScrumView = false
newScrumData = DailyScrum.Data()

The first line causes the sheet to start animating await, but then right after we clear out the scratch data.

And the reason the data doesn’t reset when swiping down on the sheet is because we only clear the data when the toolbar buttons are tapped. We need to further tap into the onDismiss to clear the data.

The matter of the fact is that it’s annoying and downright error prone to manage two pieces of state for the presentation of a sheet. We should be able to just do this:

newScrumData = nil

…and that should simultaneously mean dismiss the sheet and clear out the data. But sadly, SwiftUI’s tools just don’t allow us to do this.

The final little bit of code in this file is an onChange view modifier for observing when the scene goes to background, in which case we save:

.onChange(of: scenePhase) { phase in
    if phase == .inactive { saveAction() }
}

That’s all there is to this file, and there are two places we can look to next for logic and behavior in the application. The DetailEditView, which is the form that allows us to edit the details of a scrum. And the DetailView, which is the view we drill down to.

The DetailEditView is quite a bit simpler, so let’s start with that.

At the top of the view we can see the data it needs to do its job:

struct DetailEditView: View {
    @Binding var data: DailyScrum.Data
    @State private var newAttendeeName = ""
    …
}

And the body of the view is just a big form that constructs a bunch of components and hooks up their bindings to the data binding so that any changes are instantly made to the data. There’s not much else interesting in this view.

Let’s hop over to the DetailView.

At the top of the view we can see all the data it needs to do its job:

struct DetailView: View {
    @Binding var scrum: DailyScrum
    @State private var data = DailyScrum.Data()
    @State private var isPresentingEditView = false

    …
}

It is handed a binding of a scrum from whoever creates the view. Previously we saw that happened in the list, where the ForEach derives a binding for each element of a collection.

And interestingly, the same pattern we just saw of holding onto a boolean and scratch piece of state is repeated here because this screen supports navigating to the edit screen. This is starting to seem like a red flag. Not only is this an imprecise way of modeling the domain that requires delicate care to manually manage, but we are repeating it multiple times in the application.

The body of the view is pretty straightforward as there isn’t much behavior in this view. The majority of the view is just creating the various stacks and text views to display all the data in a scrum.

There are two notable exceptions. First, there’s a navigation link for going into the meeting view, which is where the timer and speech transcription behavior is:

NavigationLink(destination: MeetingView(scrum: $scrum)) {
    …
}

Again notice that this is a fire-and-forget navigation link, which means there is no way to programmatically deep link into this view. The only way to get to the meeting view is for the user to literally tap the “Start Meeting” button.

The other notable thing is the code for showing the sheet. It follows a similar pattern that we saw in the ScrumsView, where the toolbar is attached with buttons for cancelling and saving:

.sheet(isPresented: $isPresentingEditView) {
    NavigationView {
        DetailEditView(data: $data)
            .navigationTitle(scrum.title)
            .toolbar {
                ToolbarItem(
                    placement: .cancellationAction
                ) {
                    Button("Cancel") {
                        isPresentingEditView = false
                    }
                }
                ToolbarItem(
                    placement: .confirmationAction
                ) {
                    Button("Done") {
                        isPresentingEditView = false
                        scrum.update(from: data)
                    }
                }
            }
    }
}

However, this code deviates from the last in that it has decided to not clear the scratch scrum data upon completion, but rather it does so upon presenting:

.toolbar {
    Button("Edit") {
        isPresentingEditView = true
        data = scrum.data
    }
}

This just shows another flaw with this approach of domain modeling. There are two completely different ways to manage the data. You can either clear the data when presenting or when dismissing. That inconsistency will infect your code base and make it harder to understand over time.

It would be far better if data was optional, and then the hydration of the state:

data = …

…would automatically present the sheet, and nil’ing out the state:

data = nil

…would automatically dismiss the sheet. There would be no room for interpretation.

We are now ready to hop over to the last interesting view in the application, the MeetingView, and it’s a doozy. This is by far the most complicated view because it manages a timer, determines when to go to the next speaker, plays a sound effect, and interacts with the Speech framework to transcribe audio.

At the top of the view we can see all the data it needs to do its job:

struct MeetingView: View {
    @Binding var scrum: DailyScrum
    @StateObject var scrumTimer = ScrumTimer()
    @StateObject var speechRecognizer = SpeechRecognizer()
    @State private var isRecording = false
    private var player: AVPlayer {
        AVPlayer.sharedDingPlayer
    }

    …
}

It takes a binding of a scrum because it mutates the scrum to append the transcript to the history.

It also appears that there are two new observable objects called ScrumTimer and SpeechRecognizer that are stored as @StateObjects, and presumably they encapsulate the logic for those two processes.

There is also some isRecording state, but it doesn’t seem like it’s actually used in any significant way so we will ignore it. And finally there’s an AVPlayer, which is used to play the ding sound effect when the current speaker changes.

The body of the view mostly calls out to other views, such as MeetingHeaderView, MeetingTimerView and MeetingFooterView, but those views are inert with no behavior. They just take data and output views, so we don’t need to look at them in much detail.

The only interesting thing happening in this view is the onAppear and onDisappear. They do a bunch of work to start up the start objects and tear them down. There is a significant amount of logic here to coordinate, and by having it all in the view we don’t have any hope of being able to test any of this.

That’s all there is to this view, which means all of its behavior must be stashed away in those two observable objects we saw a moment ago.

We aren’t going to spend a ton of time trying to understand them because honestly they are pretty difficult to grok. The ScrumTimer manages a bunch of state and seems to encapsulate the behavior of resetting, starting and stopping the timer, as well as detecting when it’s time to move on to the next speaker.

The SpeechRecognizer encapsulates the behavior of asking for permission to transcribe audio, starting the speech recognizer, listening for new transcripts being delivered, and then tearing down the speech recognizer. This logic is scattered about quite a bit, and so it’s difficult to really understand in depth, and so we won’t try.

So, that’s the code for the Scrumdinger application. It’s quite complex. In fact, it’s probably the most generally complex SwiftUI demo application that Apple has made available.

But, along the way we saw a number of shortcomings:

  • Certain domains were modeled in less than ideal ways due to deficiencies in SwiftUI’s tools. This caused the code to be more complex because it needed to explicitly manage two pieces of state where one would do, and we will never have proof from the compiler that we did it correctly.
  • There is a significant amount of logic in the views. This makes the views very complex and difficult to understand.
  • Due to the use of local @State and fire-and-forget navigation links, there is no capability to programmatically deep link into this application. This means if we wanted to support URL deep linking or push notifications, we would have to undergo significant refactoring to unlock those capabilities.
  • None of the dependencies are controlled, such as the timer or speech recognizer. We are just accessing those APIs directly in the observable objects, which makes it difficult to use Xcode previews and difficult to write tests.
  • Speaking of tests, there are no tests in this project, and it’s probably because most of it is not testable. Half the logic is in the views and the other half reaches out to uncontrolled dependencies.

Introducing "Standups"

So, let’s start rebuilding this app, and along the way we will address all of these shortcomings, and a whole lot more.

I’ve got a brand new project ready for us, but I’ve made a few key changes from Apple’s code sample. First, we’ve decided to name this project “Standups” just to differentiate ourselves a little bit. It’s nothing against scrum, we just like “standups” better.

And second we have maxed out the concurrency warnings:

SWIFT_STRICT_CONCURRENCY = complete

We going to make use of Swift’s concurrency tools in the upcoming episodes, and so we want to be notified as early as possible when we are doing something that is incorrect.

Let’s begin.

Recall that the entry point of the Scrumdinger app housed a navigation view and at the root of that view was the list of scrums. Let’s create a new file for the list of standups.

We’ll paste in the scaffolding for a list view wrapped up in a NavigationStack view:

import SwiftUI

struct StandupsList: View {
  var body: some View {
    NavigationStack {
      List {
      }
      .navigationTitle("Daily Standups")
    }
  }
}

struct StandupsList_Previews: PreviewProvider {
  static var previews: some View {
    StandupsList()
  }
}

And we will use this view in the entry point of the app:

import SwiftUI

@main
struct StandupsApp: App {
  var body: some Scene {
    WindowGroup {
      StandupsList()
    }
  }
}

OK, we now have our very first view to start putting in some real visuals.

First we are going to introduce an observable object to power the behavior for this screen. We prefer this over using local @State because it makes the logic for our views testable. So, let’s paste in some scaffolding:

final class StandupsListModel: ObservableObject {

}

In this model we want to hold an array of all the standups, but to do that we need some data types that describe our domain. We will take the types from Scrumdinger almost verbatim, but with a few small changes.

import SwiftUI

struct Standup: Equatable, Identifiable, Codable {
  let id: UUID
  var attendees: [Attendee] = []
  var duration = Duration.seconds(60 * 5)
  var meetings: [Meeting] = []
  var theme: Theme = .bubblegum
  var title = ""

  var durationPerAttendee: Duration {
    self.duration / self.attendees.count
  }
}

struct Attendee: Equatable, Identifiable, Codable {
  let id: UUID
  var name: String
}

struct Meeting: Equatable, Identifiable, Codable {
  let id: UUID
  let date: Date
  var transcript: String
}

enum Theme:
  String,
  CaseIterable,
  Equatable,
  Hashable,
  Identifiable,
  Codable
{
  case bubblegum
  case buttercup
  case indigo
  case lavender
  case magenta
  case navy
  case orange
  case oxblood
  case periwinkle
  case poppy
  case purple
  case seafoam
  case sky
  case tan
  case teal
  case yellow

  var id: Self { self }

  var accentColor: Color {
    switch self {
    case
      .bubblegum,
      .buttercup,
      .lavender,
      .orange,
      .periwinkle,
      .poppy,
      .seafoam,
      .sky,
      .tan,
      .teal,
      .yellow:

      return .black
    case .indigo, .magenta, .navy, .oxblood, .purple:
      return .white
    }
  }

  var mainColor: Color { Color(self.rawValue) }

  var name: String { self.rawValue.capitalized }
}

We’ve renamed a few things, like DailyScrum to Standup, and we have decided to represent the duration of meetings using the new Duration data type in Swift 5.7. Otherwise everything is pretty much the same.

There’s a type that represents a meeting, and it has many attendees and many meetings. An attendee is just a type with a name and ID, and a meeting is a type with a date, transcript and ID.

Before moving on, there is already one thing we can do to modernize this style of designing domain models for our applications. Notice that each of the 3 data models is Identifiable and uses a UUID for an identifier. This means it would be possible to check for the equality between a Standup.ID and an Attendee.ID, even though that makes no sense.

If we ever did accidentally do that it would be a serious bug because it will always be false. It couldn’t ever be true. So, we would like to make such accidents compile errors instead of subtle, runtime bugs.

To do this we can used our Tagged library, which allows us to strengthen the types of our identifiers, among other things. We can start by importing the library:

import Tagged

Xcode will helpfully add it to our project and link it to our application.

Then we can upgrade each model’s identifier to be tagged by the model type itself:

struct Standup: Equatable, Identifiable, Codable {
  let id: Tagged<Self, UUID>
  …
}

struct Attendee: Equatable, Identifiable, Codable {
  let id: Tagged<Self, UUID>
  …
}

struct Meeting: Equatable, Identifiable, Codable {
  let id: Tagged<Self, UUID>
  …
}

This makes each model’s id a completely different type. If you ever accidentally compare two different model ids:

func check(standup: Standup, attendee: Attendee) -> Bool {
  standup.id == attendee.id
}

…you will get a compiler error:

🛑 Binary operator ‘==’ cannot be applied to operands of type ‘Tagged<Standup, UUID>’ and ‘Tagged<Attendee, UUID>’

With that done, our model can hold onto an array of stand ups:

class StandupsListModel: ObservableObject {
  @Published var standups: [Standup]

  init(standups: [Standup] = []) {
    self.standups = standups
  }
}

And the view can hold onto the model:

struct StandupsList: View {
  @ObservedObject var model: StandupsListModel
  …
}

We are not using a @StateObject, because as we’ve mentioned before, they create little islands of isolated behavior that don’t play well with deep linking and integrating feature behavior.

With that done we need to update anywhere we construct a standups list view to also construct a model:

StandupsList(model: StandupsListModel())

Now we can use the ForEach view to render a row for each element of the array:

ForEach(self.model.standups) { standup in

}

Next we want to render a card view for each row, and we will just take that view directly from Scrumdinger. It was a simple, inert, behavior-less view, and so it plugs into quite simply:

struct CardView: View {
  let standup: Standup

  var body: some View {
    VStack(alignment: .leading) {
      Text(self.standup.title)
        .font(.headline)
      Spacer()
      HStack {
        Label(
          "\(self.standup.attendees.count)",
          systemImage: "person.3"
        )
        Spacer()
        Label(
          self.standup.duration.formatted(.units()),
          systemImage: "clock"
        )
        .labelStyle(.trailingIcon)
      }
      .font(.caption)
    }
    .padding()
    .foregroundColor(self.standup.theme.accentColor)
  }
}

struct TrailingIconLabelStyle: LabelStyle {
  func makeBody(
    configuration: Configuration
  ) -> some View {
    HStack {
      configuration.title
      configuration.icon
    }
  }
}

extension LabelStyle where Self == TrailingIconLabelStyle {
  static var trailingIcon: Self { Self() }
}

And now the CardView can be put into the ForEach:

ForEach(self.model.standups) { standup in
  CardView(standup: standup)
    .listRowBackground(standup.theme.mainColor)
}

We finally have enough view hierarchy in place to actually see something on the screen. We can’t see it in the simulator because we don’t yet have the behavior for adding a standup, but we should be able to see it in the Xcode preview.

We just need to populate the model with some standups. We can start by defining a mock in the Models.swift file so that we can easily access it from anywhere:

extension Standup {
  static let mock = Self(
    id: Standup.ID(UUID()),
    attendees: [
      Attendee(id: Attendee.ID(UUID()), name: "Blob"),
      Attendee(id: Attendee.ID(UUID()), name: "Blob Jr"),
      Attendee(id: Attendee.ID(UUID()), name: "Blob Sr"),
      Attendee(id: Attendee.ID(UUID()), name: "Blob Esq"),
      Attendee(id: Attendee.ID(UUID()), name: "Blob III"),
      Attendee(id: Attendee.ID(UUID()), name: "Blob I"),
    ],
    duration: .seconds(60),
    meetings: [
      Meeting(
        id: Meeting.ID(UUID()),
        date: Date().addingTimeInterval(-60 * 60 * 24 * 7),
        transcript: """
          Lorem ipsum dolor sit amet, consectetur \
          adipiscing elit, sed do eiusmod tempor \
          incididunt ut labore et dolore magna aliqua. \
          Ut enim ad minim veniam, quis nostrud \
          exercitation ullamco laboris nisi ut aliquip \
          ex ea commodo consequat. Duis aute irure \
          dolor in reprehenderit in voluptate velit \
          esse cillum dolore eu fugiat nulla pariatur. \
          Excepteur sint occaecat cupidatat non \
          proident, sunt in culpa qui officia deserunt \
          mollit anim id est laborum.
          """
      )
    ],
    theme: .orange,
    title: "Design"
  )
}

With that we can add the mock to the preview array:

standups: [
  .mock
]

And we can now see some UI in the preview.

Next time: Adding behavior

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.

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.

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…next time!

This episode is free for everyone.

Subscribe to Point-Free

Access all past and future episodes when you become a subscriber.

See plans and pricing

Already a subscriber? Log in

References

Getting started with Scrumdinger

Apple

Learn the essentials of iOS app development by building a fully functional app using SwiftUI.

Standups 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.

Tagged

Brandon Williams & Stephen Celis • Monday Apr 16, 2018

Tagged is one of our open source projects for expressing a way to distinguish otherwise indistinguishable types at compile time.

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.

Fruta: Building a Feature-Rich App with SwiftUI

Apple

Create a shared codebase to build a multiplatform app that offers widgets and an App Clip.

Downloads