How to Programmatically Push and Pop Views in SwiftUI with NavigationDestinationLink
Note: This post was written in the summer of 2019 during the iOS 13 beta season. The content of this article is no longer relevant or helpful, as NavigationDestinationLink has since been deprecated. This post will continue to be available for historical reference.
If you've been working with SwiftUI lately, you've probably heard of these navigation APIs: PresentationLink
(previously PresentationButton
), NavigationLink
(previously NavigationButton
), and the presentation()
modifier.
The *Link
views are fine options if you don't need to programmatically dismiss the modal or navigation. The presentation()
modifier works well if you need to be able to control the presentation state of the modal. But what is the equivalent for navigation? Such an API needs to exist, as the inability to pop from a navigation view stack would be a severe constraint on any SwiftUI app’s design.
From searching around online, I came across NavigationDestinationLink
. But it didn't seem like anyone knew how it was supposed to work, and in previous betas it always resulted in a crash. I searched on GitHub and I found just one project that was using this API. I tried it out in my project and it worked! Except one issue – it broke the native "Back" button and back dismissal gesture.
I spent several hours toying with the API, trying to get it to work. Using NavigationDestinationLink
correctly has some key constraints:
- The link can only be accessed from a View's body, or else you get this compilation error: "Fatal error: Reading NavigationDestinationLink outside of
View.body
." - The link's
presented
binding should only be modified when a push or pop occurs, or else native back gestures won't work. You can't just have thepresented
binding updated whenever SwiftUI renders the presenting View'sbody
. - Since the Link has to be constructed at initialization time, the destination View also needs to be constructed at initialization time. This means that you cannot pass ordinary
onDismiss
-style callbacks to the destination View directly, because you can't create any mutating self-referencing closures in a struct'sinit
.
As is seemingly the solution to many problems with SwiftUI, the answer is to use Combine. Here's my solution:
import Combine
import SwiftUI
struct DetailView: View {
var onDismiss: () -> Void
var body: some View {
Button(
"Here are details. Tap to go back.",
action: self.onDismiss
)
}
}
struct RootView: View {
var link: NavigationDestinationLink<DetailView>
var publisher: AnyPublisher<Void, Never>
init() {
let publisher = PassthroughSubject<Void, Never>()
self.link = NavigationDestinationLink(
DetailView(onDismiss: { publisher.send() }),
isDetail: false
)
self.publisher = publisher.eraseToAnyPublisher()
}
var body: some View {
VStack {
Button("I am root. Tap for more details.", action: {
self.link.presented?.value = true
})
}
.onReceive(publisher, perform: { _ in
self.link.presented?.value = false
})
}
}
struct ContentView: View {
var body: some View {
NavigationView {
RootView()
}
}
}
Here's what it looks like:
The isDetail Parameter
On iPadOS, when NavigationView is rendered with a two-column, master-detail format, the isDetail parameter becomes important. If isDetail
is true, then the destination view is pushed into the second "detail" column. If it's false, it pushes into the primary "master" column. Note, when it's true and after it has been presented, it doesn't seem that setting the link's presented state to false has any effect.
I hope this is helpful to others. I'm just glad that it's possible! 😅