Push View programmatically in callback, SwiftUI

IosSwiftSwiftui

Ios Problem Overview


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.

Ios Solutions


Solution 1 - Ios

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()
    

Solution 2 - Ios

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
                }
            }
        }
    }
}

Solution 3 - Ios

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()
   }
  }
 }
}

Solution 4 - Ios

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

Solution 5 - Ios

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))

Solution 6 - Ios

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")
            }
        }
    }
}

Attributions

All content for this solution is sourced from the original question on Stackoverflow.

The content on this page is licensed under the Attribution-ShareAlike 4.0 International (CC BY-SA 4.0) license.

Content TypeOriginal AuthorOriginal Content on Stackoverflow
QuestionBohdan SavychView Question on Stackoverflow
Solution 1 - IosBohdan SavychView Answer on Stackoverflow
Solution 2 - IosLoganView Answer on Stackoverflow
Solution 3 - IosDi_NerdView Answer on Stackoverflow
Solution 4 - IosBryan BartowView Answer on Stackoverflow
Solution 5 - IosIgor KasuanView Answer on Stackoverflow
Solution 6 - IosPitchbloasView Answer on Stackoverflow