Monday Nov 21, 2022
Today we are releasing the biggest update to our SwiftUINavigation library since its first release one year ago. This brings support for new iOS 16 APIs, bug fixes for some of Apple’s navigation tools, better support for alerts and confirmation dialogs, and improved documentation.
Join us for a quick overview of the new features, and be sure to update to 0.4.0 to get access to all of this, and more:
iOS 16 largely reinvented the way drill-down navigations are performed by introducing a
new top-level view, NavigationStack
, a new view modifier,
navigationDestination
, and new initializers on
NavigationLink
. These new tools allow for greater decoupling of
source and destination of navigation, and allow for better managing of deep stacks of
features.
Our library brings a new tool to the table, and it’s built on the back of the
navigationDestination(isPresented:)
view modifier, which
allows driving navigation from a boolean binding. This tool fixes one of the biggest
drawbacks to NavigationLink
, which is that it was difficult to use in a list view since a
drill-down would only occur if the row was visible in the list. This means you could not
programmatically deep link to a screen if the row was not currently visible.
The navigationDestination
view modifier fixes this by allowing you to have a single place
to express navigation, rather than embedding it in each row of the list:
func navigationDestination<V>(
isPresented: Binding<Bool>,
destination: () -> V
) -> some View where V : View
However, a boolean binding is too simplistic of a domain modeling tool to use. What if you wanted to control navigation via a piece of optional state, and further hand that state to the destination view?
This is why our library comes with an additional overload, named
navigationDestination(unwrapping:)
, which can drive navigation
from a binding to an optional:
public func navigationDestination<Value, Destination: View>(
unwrapping value: Binding<Value?>,
@ViewBuilder destination: (Binding<Value>) -> Destination
) -> some View {
This makes it easy to have a list of data for which you want to drill down when a row is tapped:
struct UsersListView: View {
@State var users: [User]
@State var editingUser: User?
var body: some View {
List {
ForEach(self.users) { user in
Button("\(user.name)") { self.editingUser = user }
}
}
.navigationDestination(unwrapping: self.$editingUser) { $user in
EditUserView(user: $user)
}
}
}
This works great if you only have a single destination to navigate to. But, if you need to
support multiple destinations, you may be tempted to hold multiple pieces of optional state.
However, that leads to an explosion of invalid states, such as when more than 1 is non-nil
at the same time. SwiftUI considers that a user error, and can lead to the interface
becoming non-responsive or even crash.
Subscribe to Point-Free through our Black Friday sale at a 30% discount! Learn how we motivated and built the SwiftUI Navigation library along with many other topics.
That’s why our library ships with another overload, named
navigationDestination(unwrapping:case:)
, which allows
driving multiple destinations from a single piece of enum state:
public func navigationDestination<Enum, Case, Destination: View>(
unwrapping enum: Binding<Enum?>,
case casePath: CasePath<Enum, Case>,
@ViewBuilder destination: (Binding<Case>) -> Destination
) -> some View {
This allows you to model all destinations for a feature as a single enum and a single piece of optional state pointing to that enum. For example, a list with rows for users and categories for which tapping either should drill-down to the corresponding edit screen:
struct UsersListView: View {
@State var categories: [Category]
@State var users: [User]
@State var destination: Destination?
enum Destination {
case edit(user: User)
case edit(category: Category)
}
var body: some View {
List {
Section(header: Text("Users")) {
ForEach(self.users) { user in
Button("\(user.name)") { self.destination = .edit(user: user) }
}
}
Section(header: Text("Categories")) {
ForEach(self.categories) { category in
Button("\(category.name)") { self.destination = .edit(category: category) }
}
}
}
.navigationDestination(
unwrapping: self.$destination,
case: /Destination.edit(user:)
) { $user in
EditUserView(user: $user)
}
.navigationDestination(
unwrapping: self.$destination,
case: /Destination.edit(category:)
) { $category in
EditCategoryView(user: $category)
}
}
}
This makes it so that the compiler can prove that two destinations are never active at the same time. After all, the destinations are modeled on an enum, and cases of an enum are mutually exclusive.
The navigationDestination(isPresented:)
view modifier
released in iOS 16 is powerful, and the above shows we can build powerful APIs on top of it,
however it does have some bugs.
If you launch your application with the navigation state already hydrated, meaning you should be drilled down into the destination, the UI will be completely broken. It will not be drilled down, and worse tapping on the button to force the drill down will not work.
We filed a feedback (and we recommend you duplicate it!), and this simple example shows the problem:
struct UserView: View {
@State var isPresented = true
var body: some View {
Button("Go to destination") {
self.isPresented = true
}
.navigationDestination(isPresented: self.$isPresented) {
Text("Hello!")
}
}
}
This is pretty disastrous. If you are using navigationDestination(isPresented:)
in your
code you simply will not be able to support things like URL deep linking or push
notification deep linking.
However, we were able to fix that bug in our APIs. If you use
navigationDestination(unwrapping:case:)
then you can rest assured that deep linking will
work correctly, and it will work any number of levels deep. This also fixes a long standing
bug in iOS <16’s NavigationLink
, which is notorious for not being able to deep link more
than 2 levels deep.
Our SwiftUINavigation has had support for better alert and confirmation dialog APIs using optionals and enums from the very beginning, but with the 0.4.0 release we have made them even more powerful.
The library now ships data types that allow you to describe the presentation of an alert or
confirmation dialog in a way that is friendlier to testing (i.e. they are Equatable
). This
makes it possible to store these values in your ObservableObject
conformances so that you
can get test coverage on any logic.
For example, suppose you have an interface with a button that can delete an inventory item,
but only if it is not “locked.” We can model this in our ObservableObject
as a published
property of AlertState
, along with an enum to describe any actions the user can take in
the alert:
@Published var alert: AlertState<AlertAction>
enum AlertAction {
case confirmDeletion
}
Then you are free to hydrate this state at anytime to represent that an alert should be displayed:
func deleteButtonTapped() {
if item.isLocked {
self.alert = AlertState {
TextState("Cannot be deleted")
} message: {
TextState("This item is locked, and so cannot be deleted.")
}
} else {
self.alert = AlertState {
TextState("Delete?")
} actions: {
ButtonState(role: .destructive, action: .confirmDeletion) {
TextState("Yes, delete")
}
ButtonState(role: cancel) {
TextState("Nevermind")
}
} message: {
TextState(#"Are you sure you want to delete "\(item.name)"?"#)
}
}
}
And the final step for the model layer is to implement a method that handles when an alert button is tapped:
func alertButtonTapped(_ action: AlertAction) {
switch action {
case .confirmDeletion:
self.inventory.remove(id: item.id)
}
}
Then, to make the view show the alert when the alert
state becomes non-nil
we just have
to make use of the alert(unwrapping:)
API that ships with our
library:
struct ItemView: View {
@ObservedObject var model: ItemModel
var body: some View {
Form {
…
}
.alert(unwrapping: self.$model.alert) { action in
self.model.alertButtonTapped(action)
}
}
}
Notice that there is no logic in the view for what kind of alert to show. All of the logic for when to display the alert and what information is displayed (title, message, buttons) has all been moved into the model, and therefore very easy to test.
To test, you can simply assert against any parts of the alert state you want. For example,
if you want to verify that the message of the alert is what you expected, can just use
XCTAssertEqual
:
let headphones = Item(…)
let model = ItemModel(item: headphones)
model.deleteButtonTapped()
XCTAssertEqual(
model.alert.message,
TextState(#"Are you sure you want to delete "Headphones"?"#)
)
Start taking advantage of all of the powerful domain modeling tools that Swift comes with (enums and optionals!) by adding SwiftUINavigation to your project today!
👋 Hey there! If you got this far, then you must have enjoyed this post. You may want to also check out Point-Free, a video series covering advanced programming topics in Swift. Consider subscribing today!