59

It seems to me that Apple is encouraging us to give up using UIViewController in SwiftUI, but without using view controllers, I feel a little bit powerless. What I would like is to be able to implement some sort of ViewModel which will emit events to View.

ViewModel:

public protocol LoginViewModel: ViewModel {
  var onError: PassthroughSubject<Error, Never> { get }
  var onSuccessLogin: PassthroughSubject<Void, Never> { get }
}

View:

public struct LoginView: View {
  fileprivate let viewModel: LoginViewModel
  
  public init(viewModel: LoginViewModel) {
    self.viewModel = viewModel
  }
  
  public var body: some View {
    NavigationView {
      MasterView()
        .onReceive(self.viewModel.onError, perform: self.handleError)
        .onReceive(self.viewModel.onSuccessLogin, perform: self.handleSuccessfullLogin)
    }
  }

  func handleSuccessfullLogin() {
    //push next screen
  }
  
  func handleError(_ error: Error) {
    //show alert
  }
}

Using SwiftUI, I don't know how to push another controller if login is successful

Also, I would appreciate any advice about how to implement what I want in a better way. Thanks.

Bohdan Savych
  • 3,310
  • 4
  • 28
  • 47
  • You are correct, if you have a `SwiftUI` project, IMHO you should only use a `UIViewController` if you need to use something like delegates or something *not* (yet) available in native SwiftUI. Let's start at square one; your model needs to **drive** the views, not the reverse. it likely should be a class instance that conforms to the `ObservableObject` protocol. (Beware, this is still early beta, and has changed in beta 5.) As for the next two things? Ask one at a time. Show code. Be aware that #2 (show alert) has undergone THREE changes over these 5 betas. –  Aug 01 '19 at 19:23
  • Checkout SwiftUI navigation library github.com/canopas/UIPilot for easy navigation. – jimmy0251 Feb 24 '22 at 10:34

6 Answers6

84

I've found the answer. If you want to show another view on callback you should

  1. Create state @State var pushActive = false

  2. When ViewModel notifies that login is successful set pushActive to true

    func handleSuccessfullLogin() {
        self.pushActive = true
        print("handleSuccessfullLogin")
    }
    
  3. Create hidden NavigationLink and bind to that state

    NavigationLink(destination: 
       ProfileView(viewModel: ProfileViewModelImpl()),
       isActive: self.$pushActive) {
         EmptyView()
    }.hidden()
    
LinusGeffarth
  • 27,197
  • 29
  • 120
  • 174
Bohdan Savych
  • 3,310
  • 4
  • 28
  • 47
14

I'm adding some snippets here because I think it simplifies some things and makes reusing navigation links easier:

1. Add View Navigation Extensions

extension View {
    func navigatePush(whenTrue toggle: Binding<Bool>) -> some View {
        NavigationLink(
            destination: self,
            isActive: toggle
        ) { EmptyView() }
    }

    func navigatePush<H: Hashable>(when binding: Binding<H>,
                                   matches: H) -> some View {
        NavigationLink(
            destination: self,
            tag: matches,
            selection: Binding<H?>(binding)
        ) { EmptyView() }
    }

    func navigatePush<H: Hashable>(when binding: Binding<H?>,
                                   matches: H) -> some View {
        NavigationLink(
            destination: self,
            tag: matches,
            selection: binding
        ) { EmptyView() }
    }
}

Now, you can call on any view (make sure they (or a parent) are in a navigation view)

2. Use at leisure

struct Example: View {
    @State var toggle = false
    @State var tag = 0

    var body: some View {
        NavigationView {
            VStack(alignment: .center, spacing: 24) {
                Text("toggle pushed me")
                    .navigatePush(whenTrue: $toggle)
                Text("tag pushed me (2)")
                    .navigatePush(when: $tag, matches: 2)
                Text("tag pushed me (4)")
                    .navigatePush(when: $tag, matches: 4)

                Button("toggle") {
                    self.toggle = true
                }

                Button("set tag 2") {
                    self.tag = 2
                }

                Button("set tag 4") {
                    self.tag = 4
                }
            }
        }
    }
}
Logan
  • 52,262
  • 20
  • 99
  • 128
4

as @Bhodan mentioned you can do it by changing state

Using EnvironmentObject with SwiftUI

  1. Add UserData ObservableObject :
class UserData: ObservableObject, Identifiable {

    let id = UUID()
    @Published var firebase_uid: String = ""
    @Published var name: String = ""
    @Published var email: String = ""
    @Published var loggedIn: Bool = false
}

the loggedIn property will be used to monitor when a change in user logs in or out

  1. Now add it as an @EnvironmentObject in your SceneDelegate.swift file in Xcode this just makes it so its accessible everywhere in your app
class SceneDelegate: UIResponder, UIWindowSceneDelegate {

    var window: UIWindow?


    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
        // If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
        // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).

        // Create the SwiftUI view that provides the window contents.
        let userData = UserData()
        let contentView = ContentView().environmentObject(userData)

        // Use a UIHostingController as window root view controller.
        if let windowScene = scene as? UIWindowScene {
            let window = UIWindow(windowScene: windowScene)
            window.rootViewController = UIHostingController(rootView: contentView)
            self.window = window
            window.makeKeyAndVisible()
        }
    }

Once you make any change to the loggedIn property any UI that is Binded to it will respond to the true/false value change

the as @Bhodan mentioned just add this to your view and it will respond to that change


struct LoginView: View {
@EnvironmentObject var userData: UserData

var body: some View {
NavigationView {
VStack {
NavigationLink(destination: ProfileView(), isActive: self.$userData.loggedin) {
    EmptyView()
    }.hidden()
   }
  }
 }
}
Malcolm
  • 289
  • 2
  • 9
Di Nerd Apps
  • 770
  • 8
  • 15
3

As of beta 5, NavigationLink is the mechanism used to programmatically push views. You can see an example of it here.

2

CleanUI makes it extremely easy.

import SwiftUI
import CleanUI

struct ContentView: View {
    var body: some View {
        NavigationView {
            Button(action: {
                CUNavigation.pushToSwiftUiView(YOUR_VIEW_HERE)
            }){
                Text("Push To SwiftUI View")
            }
        }
    }
}
0

Workaround without creating additional empty views.

You can use .disabled(true) or .allowsHitTesting(false) modifiers to disable taps on NavigationLink.

Disadvantage: You loose default button tap highlighting.

NavigationLink(destination: EnterVerificationCodeScreen(), isActive: self.$viewModel.verifyPinIsShowing) {
    Text("Create an account")
}
.allowsHitTesting(false) // or .disabled(true) 
.buttonStyle(ShadowRadiusButtonStyle(type: .dark, height: 38))
Igor Kasuan
  • 792
  • 2
  • 10
  • 25