How to Programmatically Push and Pop Views in SwiftUI with NavigationDestinationLink

July 10, 2019

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:

  1. 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.”
  2. 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 the presented binding updated whenever SwiftUI renders the presenting View’s body.
  3. 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’s init.

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:

Navigation Destination Link Demo on iPad Navigation Destination Link Demo on iPad

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! 😅


Ryan Ashcraft

Written by Ryan Ashcraft, a software engineer in the SF Bay Area. Follow @ryanashcraft on Twitter.