SwiftUI.NavigationView: How to push a view without another view?

I have two views:

  1. An email/password view.
  2. A two factor code view.

Now, it would seem a NavigationView is the most suitable type to move the user between these views.

For example, when the user enters a recognized email and password, the two factor view pushes in. If the user decides to use a different credential, they can tap or gesture back. Or they do not enter the code from the second factor fast enough, they should be sent back to the email/password view.

Right now, I can't seem to do this without having a button the user must use themselves. Scouring the internet doesn't seem to give any insights.

Any ideas?

Thanks for your time.

struct ContentView: View {
    
    var body: some View {
        LoginView()
    }
}

enum Stage: Hashable {
    case form
    case twoFactor
}

struct LoginView: View {
    
    @State var stage: Stage? = .form
    
    var body: some View {
        NavigationView {
            LoginFormView(stage: $stage)
            
            NavigationLink(destination: TwoFactorAuthenticationView(), tag: Stage.twoFactor, selection: $stage) {
                EmptyView()
            }
        }
    }
}

struct LoginFormView: View {
    @ObservedObject var loginForm = LoginForm()
    @Binding var stage: Stage?
        
    var body: some View {
        VStack {
            TextField("Email", text: $loginForm.email)
                .textContentType(.emailAddress)
            
            SecureField("Password", text: $loginForm.password, onCommit: {
                self.logIn()
            }).textContentType(.password)
            
            Button(action: {
                self.logIn()
            }, label: {
                Text("Log In")
            })
        }.navigationBarTitle("Log In")
    }
    
    func logIn() {
        loginForm.logIn { result in
            switch result {
            case .success(let session):
                print(session)
                self.stage = .twoFactor
            case .failure(let error):
                print(error)
            }
        }
    }
}

struct TwoFactorAuthenticationView: View {    
    var body: some View {
        Text("Hi").navigationBarTitle("2FA")
    }
}

NavigationLink provides a variety of initializer that take an isActive binding. When isActive is true, the destination will be displayed.

Thanks for the reply. Unfortunately, setting isActive doesn't seem to work: nothing changes. Any ideas what am I doing wrong?

The idea of this following example is: the user sees "waiting..." for two seconds until the word "Green" is pushed. But nothing happens.

struct ContentView: View {
    var body: some View {
        ColourView()
    }
}

struct ColourView: View {
    enum PushedColor: Hashable {
        case red
        case green
    }
    
    @State var isRedActive = true
    @State var isGreenActive = false
    
    @State var pushed: PushedColor?
    
    var body: some View {
        NavigationView {
            Text("Waiting...")
            
            NavigationLink(destination: Text("Red"), tag: .red, selection: $pushed) {
                EmptyView()
            }
            NavigationLink(destination: Text("Green"), tag: .green, selection: $pushed) {
                EmptyView()
            }
//            NavigationLink(destination: Text("Red"), isActive: $isRedActive) {
//                EmptyView()
//            }
//            NavigationLink(destination: Text("Green"), isActive: $isGreenActive) {
//                EmptyView()
//            }
        }.onAppear() {
            DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(2)) {
                self.pushed = .green
//                self.isGreenActive = true
//                self.isRedActive = false
            }
        }
    }
}

To see what happens, change

NavigationLink(destination: Text("Red"), tag: .red, selection: $pushed) {
    Text("R") //EmptyView()
}
NavigationLink(destination: Text("Green"), tag: .green, selection: $pushed) {
    Text("G") //EmptyView()
}

While running your code, you still see just Waiting... on screen. The solution is easy, put everything in NavigationView in some container, let say

var body: some View {
        NavigationView {
            VStack {
                Text("Waiting...")
                
                NavigationLink(destination: Text("Red"), tag: .red, selection: $pushed) {
                    EmptyView()
                }
                NavigationLink(destination: Text("Green"), tag: .green, selection: $pushed) {
                    EmptyView()
                }
            }
        }.onAppear() {
            DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(2)) {
                self.pushed = .green
            }
        }
    }

and it will run as expected. Green appears in 2 seconds.

Thank you for your help, @jabb!

Your suggestion to remove the EmptyView() and replace it with Text is not ideal because it places the text content under the "Waiting..." text. The idea was to avoid having any visible reference to the possible next screen.

However, it seems you can still use an empty view so long as the content of the NavigationView is contained in a VStack. If you remove the VStack from the following code, it doesn't work, which is extremely counter-intuitive:

struct ColourView: View {
    
    enum PushedColor: Hashable {
        case red
        case green
    }
    
    @State var pushed: PushedColor?
    
    var body: some View {
        NavigationView {
            VStack {
                Text("Waiting...")
                
                NavigationLink(destination: Text("Red"), tag: .red, selection: $pushed) {
                    EmptyView()
                }
                
                NavigationLink(destination: Text("Green"), tag: .green, selection: $pushed) {
                    EmptyView()
                }
            }
        }
        .onAppear() {
            DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(2)) {
                self.pushed = .green
            }
        }
    }
}

So, the above code works. Remove the VStack and it doesn't.

... suggestion to remove the EmptyView() and replace it with Text is not ideal

:-) I suggest you to change it just to see (to visualize) where the trouble is!

OK, run next snippet on iPhone11 simulator

import SwiftUI

struct ContentView: View {
    var body: some View {
        NavigationView {
            Text("A")
            Text("B")
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

You see A in the middle of the screen. Rotate the device ... and you see B in the middle :-) This demonstrates your problem more clearly, I hope.

Add third View / Text("C") / and try to imagine what happens ...