Search code examples
iosswiftuiswiftui-navigationlink

SwiftUI: NavigationView/NavigationLink: launch with destination pushed?


The iOS Mail app launches with your most recently-viewed mailbox already pushed onto the navigation stack, even though it's actually a detail screen and the root view controller is the list of all mailboxes. This would be trivial to do in UIKit, since pushing onto a navigation controller is trivial and could be done, for example, on viewDidLoad or inside of applicationDidFinishLaunching.

NavigationView and NavigationLink are the APIs for performing navigation pushes/pops in SwiftUI, and NavigationLink supports a couple of APIs that mimic programmatic navigation - but I can't quite figure out how I could support this specific use case. The onAppear closure is called each time the screen appears (more like viewDidAppear than viewDidLoad), so setting a selection value corresponding to a NavigationLink would result in repeatedly pushing that detail screen when the user tries to pop. I could try to do some hacking around maintaining a State for whether the screen has been freshly viewed or not, but it doesn't seem ideal. (I've also had some issues around NavigationLink's selection API being flaky, but that's perhaps a separate issue.)

Any suggestions? How might I launch the app onto a detail screen, but otherwise have the NavigationView behave normally without any programmatic intervention?


Solution

  • If you don't need to present your view in a modal or animation, you should get the desired behavior by using programmatic navigation (a NavigationLink with selection binding).

    For example, the following should work if it's the root of your App:

    struct Item: Identifiable {
        let id: Int
        let name: String
    }
    
    struct NormalNav: View {
        @State var items: [Item] = [
            .init(id: 0, name: "One"),
            .init(id: 1, name: "Two"),
            .init(id: 2, name: "Three")
        ]
    
        @State private var selection: Int? = 1
    
        var body: some View {
            NavigationView {
                List {
                    ForEach(items) { item in
                        NavigationLink(item.name,
                                       destination: Text(item.name),
                                       tag: item.id,
                                       selection: $selection)
                    }
                }
            }
        }
    }
    

    The view above should open directly to the second item ("Two") with no animation.

    However, there's a wrinkle. If you need to present this view in a Sheet, the detail view will be pushed with an animation, which is probably not what you want.

    Building on this answer, you can suppress the animation during initial creation, but turn it back on for subsequent navigation:

    struct ContentView: View {
        @State var items: [Item] = [
            .init(id: 0, name: "One"),
            .init(id: 1, name: "Two"),
            .init(id: 2, name: "Three")
        ]
    
        @State private var selection: Int? = nil
        @State var isAnimationDisabled = true
        @State var isListPresented = false
    
        var body: some View {
            Button("Show list") {
                isListPresented = true
            }.sheet(isPresented: $isListPresented) {
                NavigationView {
                    List {
                        ForEach(items) { item in
                            NavigationLink(item.name,
                                           destination: Text(item.name),
                                           tag: item.id,
                                           selection: $selection)
                        }
                    }.animations(disabled: isAnimationDisabled)
                }.onChange(of: selection) { value in
                    if value == nil {
                        isAnimationDisabled = false
                    }
                }.onAppear {
                    selection = 1
                }
            }
        }
    }
    
    extension View {
        func animations(disabled: Bool) -> some View {
            transaction { (tx: inout Transaction) in
                guard disabled else {
                    return
                }
                tx.disablesAnimations = true
                tx.animation = .none
            }
        }
    }