Preamble To celebrate the conclusion of our 7-part series on “Modern SwiftUI,” we are releasing a blog post each day this week exploring a modern, best practice for SwiftUI development. Today we show how to more concisely model your domains for navigation in SwiftUI, but be sure to catch up on the other posts:
Navigation is one of the most difficult aspects of SwiftUI, and it’s why we have a big series of episodes dedicated to the topic. But it doesn’t have to be that way. It’s possible to model navigation in state using concise tools (e.g., optionals and enums), which makes it easy to deep link into any state imaginable in your application.
You can get really far in SwiftUI using what we call “fire-and-forget” navigation, where there is no representation of the navigation in your state. One example of this is the NavigationLink
initializer that only takes a title and destination view:
NavigationLink("Go to settings") {
SettingsView()
}
The only way to navigate to the settings view is for the user to literally tap the link. It is not possible to programmatically construct a piece of state, hand it to SwiftUI, and let SwiftUI do the rest. This means we can’t deep link into the settings screen, whether that be from a push notification, URL link, state restoration, or even after performing some asynchronous work.
This is why it’s best to use SwiftUI’s “state-driven” navigation APIs, where the presentation and dismissal of a view is represented as state in your actual domain. The sheet
modifier for presenting modals is an example of this:
struct FeatureView: View {
@State var isPresented = false
var body: some View {
Button("Show sheet") {
isPresented = true
}
.sheet(isPresented: $isPresented) {
Text("Hello!")
}
}
}
It is possible to show this sheet by simply flipping a boolean to true
. This can happen with a user action, such as the above button tap, but it can also happen without any user intervention, such as if a push notification was received.
State-driven navigation offers a lot more flexibility and power than its “fire-and-forget” counterpart, but it can also be more difficult to implement correctly.
Many navigation APIs in SwiftUI are “optional-driven”, that is, a piece of optional state determines whether or not a view is presented. For example, a modal sheet can be presented when a piece of optional state becomes non-nil
, and then be dismissed when it becomes nil
:
struct FeatureView: View {
@State var presentedValue: String?
var body: some View {
Button("Show sheet") {
presentedValue = "Hello!"
}
.sheet(item: $presentedValue) { value in
Text(value)
}
}
}
This works well, and can allow the modal to be dynamic based on data passed from the parent view.
However, sometimes it’s not powerful enough. Often we don’t want just a plain, inert value passed to the modal, but rather a full binding so that the child can make changes to the value that will be observable from the parent. In order to do this we can make use of the .sheet(unwrapping:)
view modifier that ships with our SwiftUINavigation library:
struct FeatureView: View {
@State var presentedValue: String?
var body: some View {
Button("Show sheet") {
presentedValue = "Hello!"
}
.sheet(unwrapping: $presentedValue) { $value in
TextField("Value", text: $value)
}
}
}
This will “unwrap” the Binding<String?>
to turn it into a Binding<String>
, which is handed to the modal view presented.
SwiftUI ships lots of tools for dealing with state modeled on structs (e.g., dynamic member lookup for deriving bindings) and optionals (e.g., optional-drive navigation like in .sheet(item:)
), but sadly there are no tools for enums. Enums are one of the most powerful features of Swift. They allow you to statically describe the mutually exclusive choice of a finite set of cases, and they are a great tool for modeling navigation state.
For example, in our series on “Modern SwiftUI” we rebuilt Apple’s “Scrumdinger” application from scratch, and in doing so we modeled navigation state as concisely as possible using enums.
One screen, the “standup detail” screen, has 4 possible destinations it can navigate to: an alert for deleting the standup, a sheet for editing the standup, a drill-down to a previously recorded meeting, and a drill-down to record a new meeting. If we use only the tools that SwiftUI gives us, then we would be forced to model all of these destinations as optionals:
@Published var alert: AlertState<AlertAction>?
@Published var edit: EditStandupModel?
@Published var meeting: Meeting?
@Published var record: RecordMeetingModel?
We now have 2⁴=16 states to contend with, of which only 5 are actually valid (either exactly 1 is non-nil
, or all are nil
). It doesn’t make sense to have the delete alert and edit screen open at the same time, as well as 10 other combinations that are nonsensical.
That kind of imprecision in the domain starts to leak complexity throughout the entire code base. You can never be sure of what screen is actually visible because you must check multiple pieces of state to see if they are nil
, and if new destinations are added then existing code can all of a sudden become incorrect.
For this reason we prefer to model this kind of state as an enum, which automatically bakes in compile-time proof that only one value can be instantiated at a time. This is how it looks in the actual StandupDetailModel
that powers the screen:
class StandupDetailModel: ObservableObject {
@Published var destination: Destination?
enum Destination {
case alert(AlertState<AlertAction>)
case edit(EditStandupModel)
case meeting(Meeting)
case record(RecordMeetingModel)
}
…
}
And then, in the view, we can make use of the tools that ship in our SwiftUINavigation library, which allows you to perform all styles of navigation (alerts, sheets, popovers, drill-downs, etc.) with a single, unified API that allows you to choose which case of an enum should drive the navigation for that destination:
.navigationDestination(
unwrapping: $model.destination,
case: /StandupDetailModel.Destination.meeting
) { $meeting in
MeetingView(meeting: meeting, standup: model.standup)
}
.navigationDestination(
unwrapping: $model.destination,
case: /StandupDetailModel.Destination.record
) { $model in
RecordMeetingView(model: model)
}
.alert(
unwrapping: $model.destination,
case: /StandupDetailModel.Destination.alert
) { action in
await model.alertButtonTapped(action)
}
.sheet(
unwrapping: $model.destination,
case: /StandupDetailModel.Destination.edit
) { $editModel in
EditStandupView(model: editModel)
}
With that little bit of upfront work, navigating to a particular screen is as easy as just constructing a piece of state. For example, when the “Edit” button is tapped, we can show the edit sheet by simply populating the destination
state:
destination = .edit(
withDependencies(from: self) {
EditStandupModel(standup: standup)
}
)
Or when the “Start a meeting” button is tapped, we can drill down to the record meeting screen by populating the destination
state:
destination = .record(
withDependencies(from: self) {
RecordMeetingModel(standup: standup)
}
)
Or when the “Cancel” button is tapped, we can dismiss the sheet by simply nil
-ing out the destination
state:
destination = nil
This makes navigation incredibly simple, and we can let SwiftUI handle the hard part of actually performing the animations and displaying the new UI.
But the best part is that deep linking, whether it be from push notifications or URLs or something else, can be implemented by simply constructing a deeply nested piece of state, handing it to SwiftUI, and letting it do its thing.
For example, if we wanted to deep link into the app so that we are drilled down to the standup detail screen, and then further drill down to a new meeting, it is as easy as this:
StandupsList(
model: StandupsListModel(
destination: .detail(
StandupDetailModel(
destination: .record(
RecordMeetingModel(standup: standup)
),
standup: standup
)
)
)
)
It is incredibly powerful!
So, state-driven navigation can be powerful, but you also must be care where you keep the state. To unlock the most power from state-driven navigation it must be modeled in ObservableObject
s instead of directly in views as @State
, and further, objects must be installed in the view as @ObservedObject
s rather than @StateObject
s.
The @State
and @StateObject
property wrappers are incredibly powerful, but it’s important to know that they are local to the view and cannot be influenced from the outside. They create islands of behavior for features, and so it is not easy to integrate many features’ behavior together.
In particular, this means features modeled on @State
and @StateObject
are not conducive to deep linking. Because the view owns the state it is not easy to construct all of the views in a particular state.
For example, if all of the views in our Standups application used @StateObject
instead of @ObservedObject
we would have no ability to launch the app in a very specific state, such as drilled down to the detail screen and then the record screen. But with @ObservedObject
, since the models can be passed to the view at each layer, it’s as easy as this:
StandupsList(
model: StandupsListModel(
destination: .detail(
StandupDetailModel(
destination: .record(
RecordMeetingModel(standup: standup)
),
standup: standup
)
)
)
)
For these reasons we highly recommend eschewing @StateObject
in favor of @ObservedObject
if deep linking is important to your application.
That’s it for now. We hope you have learned how to better leverage enums and optionals for making your navigation state in SwiftUI as concise as possible. Be sure to check out our SwiftUINavigation library to unlock the full power of enums in navigation state.
Check back in tomorrow for the 4th part of our “Modern SwiftUI” blog series, where we show how to take control of dependencies in your code base, rather than let them control you.