The future of SwiftUI navigation (?)
For a great overview on SwiftUI navigation, please check out this article by Paul Hudson.
As it stands today, SwiftUI presents some limitations when dealing with NavigationLink
s.
In Swift protocols in SwiftUI
we've covered how SwiftUI uses Identifiable
to manage sheets, alerts, and other views presentation, while Hashable
is used for navigation:
this is the first limiting factor as, out of the box, it makes it hard to programmatically trigger a navigation while also passing dynamic data to the destination view.
The second limiting factor is that NavigationLink
s are buttons in disguise: their declaration requires a view of some kind, which will then be part of the view hierarchy.
In this article, let's try to overcome both these limitations.
Now you're here, now you're gone
NavigationLink
s behave as triggers to push and pop views: internally, they observe a boolean binding (which can be exposed externally if needed) to determine when to do so.
If we want to pop back from a pushed view programmatically, the most common way is by setting the NavigationLink
's binding to the proper state:
struct ContentView: View {
@State var showingNavigation: Bool = false
var body: some View {
NavigationView {
NavigationLink(
"Push view",
destination: Button("Pop back", action: { showingNavigation = false }),
isActive: $showingNavigation
)
}
}
}
While this works great, we can also use another hidden trick to obtain the same effect:
entirely remove the NavigationLink
from the view hierarchy.
struct ContentView: View {
@State var showingNavigation: Bool = true
var body: some View {
NavigationView {
VStack {
if showingNavigation {
NavigationLink(
"Go to destination",
destination: Button("Hide navigation", action: { showingNavigation.toggle() })
)
}
if !showingNavigation {
Button("Show navigation", action: { showingNavigation.toggle() })
}
}
}
}
}
In this second case, we use showingNavigation
not as a NavigationLink
binding, but to decide whether the NavigationLink
is part of the view hierarchy.
If we remove the NavigationLink
while presenting, it will also pop its destination.
EmptyView
NavigationLink
s require a view as their Label
, however any view will do, therefore we can "hide" a NavigationLink
by passing an EmptyView
instance:
NavigationLink(
destination: ...,
isActive: ...,
label: { EmptyView() }
)
By doing so, we're effectively hiding the link from the view hierarchy while still preserving its push/pop trigger effects.
struct ContentView: View {
@State var showingNavigation: Bool = false
var body: some View {
NavigationView {
VStack {
NavigationLink(
destination: Button("Pop back", action: { showingNavigation = false }),
isActive: $showingNavigation,
label: { EmptyView() }
)
Button("Push view", action: { showingNavigation = true })
}
}
}
}
In this example, we're using a couple of buttons to trigger the navigation push and pop, but the same could have been done via any other logic (for example, after fetching something from the web).
While NavigationLink
is still part of the view hierarchy definition, it's now hidden and disconnected from the rest of the UI.
Identifiable x NavigationLink
Let's recap what we've covered so far:
- if a presenting
NavigationLink
is removed from the view hierarchy, its destination will pop back - using
EmptyView
asNavigationLink
'sLabel
will hide the view from the UI, while preserving its push/pop triggers
We would like to have a NavigationLink
with an Identifiable
binding instead of a Bool
or Hashable
one.
This aligns the navigation push/pop with the rest of SwiftUI view presentations (covered in the Identifiable
chapter here), enabling us to pass data between different views easily.
While SwiftUI does not offer this out of the box, as we've covered in Hashable SwiftUI bindings
, nobody stops us from creating a new extension.
We start with the following:
extension NavigationLink {
public init<V: Identifiable>(
item: Binding<V?>,
destination: ...
) {
...
}
}
Let's imagine to have an Identifiable
type defined as following:
enum ContentViewNavigation: Identifiable {
case one
case two(number: Int)
case three(text: String)
// MARK: Identifiable
var id: Int {
switch self {
case .one:
return 1
case .two:
return 2
case .three:
return 3
}
}
}
This "navigation" type has three possible destinations, where some of them (case .two
and .tree
) will also pass dynamic data to the destination view.
Our NavigationLink
extension would need to generate a different destination for each possible Identifiable
instance, therefore let's ask for a view builder function as our destination parameter:
extension NavigationLink {
public init<V: Identifiable>(
item: Binding<V?>,
destination: @escaping (V) -> Destination
) {
...
}
}
Thanks to this API we no longer need to declare a separate NavigationLink
for each destination. Therefore let's hide this NavigationLink
from the UI by declaring its Label
as an EmptyView
:
extension NavigationLink where Label == EmptyView {
public init<V: Identifiable>(
item: Binding<V?>,
destination: @escaping (V) -> Destination
) {
...
}
}
This NavigationLink
will generate a view with the correct destination only when the given binding
is not nil
: when the binding is nil
, the initialization will fail.
To make this happen our extension will implement a failable initializer (note the ?
in the init
):
extension NavigationLink where Label == EmptyView {
public init?<V: Identifiable>(
item: Binding<V?>,
destination: @escaping (V) -> Destination
) {
...
}
}
Now our NavigationLink
will only be generated when the binding has a value (and a view is pushed): as soon as the binding is set to nil
the NavigationLink
will be removed from the view hierarchy, triggering a navigation pop.
extension NavigationLink where Label == EmptyView {
public init?<V: Identifiable>(
item: Binding<V?>,
destination: @escaping (V) -> Destination
) {
if let value = item.wrappedValue {
self.init(...)
} else {
return nil
}
}
}
Lastly, we need to pass a binding to the initializer within our extension, let's make one that the official NavigationLink
API accepts:
extension NavigationLink where Label == EmptyView {
public init?<V: Identifiable>(
item: Binding<V?>,
destination: @escaping (V) -> Destination
) {
if let value = item.wrappedValue {
let isActive: Binding<Bool> = Binding(
get: { item.wrappedValue != nil },
set: { value in
// There's shouldn't be a way for SwiftUI to set `true` here.
if !value {
item.wrappedValue = nil
}
}
)
self.init(
destination: destination(value),
isActive: isActive,
label: { EmptyView() }
)
} else {
return nil
}
}
}
And with this, our extension is complete! Here's how we can use it:
struct ContentView: View {
@State private var showingNavigation: ContentViewNavigation?
var body: some View {
NavigationView {
VStack {
NavigationLink(item: $showingNavigation, destination: presentNavigation)
Button("Go to navigation one") {
showingNavigation = .one
}
Button("Go to navigation two") {
showingNavigation = .two(number: Int.random(in: 1...5))
}
Button("Go to navigation three") {
showingNavigation = .three(text: ["five", "stars"].randomElement()!)
}
}
}
}
@ViewBuilder
func presentNavigation(_ navigation: ContentViewNavigation) -> some View {
switch navigation {
case .one:
Text(verbatim: "one")
case .two(let number):
Text("two \(number)")
case .three(let text):
Text("three \(text)")
}
}
}
What NavigationLink?
Thanks to our extension, it's now simpler to pass data to any destination view while maintaining clarity in our view definition.
Compared to other SwiftUI presentations, our solution is still missing the complete removal of its definition from the view hierarchy. While this is currently not possible, we can undoubtedly hide it nicely.
Looking at our previous example, the navigation link is still within our VStack
:
struct ContentView: View {
...
var body: some View {
NavigationView {
VStack {
NavigationLink(item: $showingNavigation, destination: presentNavigation)
Button(...) { ... }
Button(...) { ... }
Button(...) { ... }
}
}
}
...
}
However, a NavigationLink
can be placed anywhere. For example, we can move it to the background, and everything would still work fine:
struct ContentView: View {
...
var body: some View {
NavigationView {
VStack {
Button(...) { ... }
Button(...) { ... }
Button(...) { ... }
}
.background(
NavigationLink(item: $showingNavigation, destination: presentNavigation)
)
}
}
...
}
As this would probably be the ideal way to use our NavigationLink
extension, we can define a helper View
function that enables us to so:
extension View {
func navigation<V: Identifiable, Destination: View>(
item: Binding<V?>,
destination: @escaping (V) -> Destination
) -> some View {
background(NavigationLink(item: item, destination: destination))
}
}
Making our final API look very similar to other SwiftUI presentations (for sheets, alerts, etc.):
struct ContentView: View {
...
var body: some View {
NavigationView {
VStack {
Button(...) { ... }
Button(...) { ... }
Button(...) { ... }
}
.navigation(item: $showingNavigation, destination: presentNavigation)
}
}
...
}
The complete working project can be found here.
Conclusions
The extensions introduced here probably won't cover 100% of the use cases. Still, I'd argue that a similar official API would be very welcome and would solve most of the implementation issues we face today.
We've seen no change on SwiftUI's navigation APIs at this year's WWDC. However, I wouldn't be surprised if something new would pop up in a new Xcode 11.4-like release, or perhaps at the next WWDC. In the meanwhile, we can always try and solve these challenges ourselves!
Did you face or are you facing any SwiftUI challenges? How did you solve it? Please let me know!
Thank you for reading, and stay tuned for more articles!