Flincorp
Flincorp

Reputation: 941

How do I wait for a Firebase function to complete before continuing my code flow in SwiftUI?

I have a Firebase function in a class (listenToUser) that works fine but I noticed that the next code (the IF ELSE) does not wait for it to complete before continuing. How can I wait for my function to be completed before continuing my code?

Portion of code of my main view :

...

@EnvironmentObject var firebaseSession: FirebaseSession_VM

...
        .onAppear {
            firebaseSession.listenToUser()
            
                if firebaseSession.firebaseUser == nil {
                    showSignInView = true
                } else {
                    showSignInStep1View = true
                }
   }

My function :

import SwiftUI
import Combine
import FirebaseAuth

class FirebaseSession_VM: ObservableObject {
    static let instance = FirebaseSession_VM()
    
    var didChange = PassthroughSubject<FirebaseSession_VM, Never>()
    
    @Published var firebaseUser: FirebaseUser_M? {
        didSet {
            self.didChange.send(self)
        }
    }
    
    var handle: AuthStateDidChangeListenerHandle?
    
    func listenToUser () {
        // monitor authentication changes using firebase
        handle = Auth.auth().addStateDidChangeListener { (auth, user) in
            if let user = user {
                self.firebaseUser = FirebaseUser_M(
                    id: user.uid,
                    email: user.email
                )
            } else {
                self.firebaseUser = nil
            }
        }
    }
}

Upvotes: 0

Views: 1057

Answers (1)

Peter Friese
Peter Friese

Reputation: 7254

Most of Firebase's API calls are asynchronous, which is why you need to either register a state listener or use callbacks.

Two side notes:

  1. You should not implement ObservableObjects as singletons. Use @StateObject instead, to make sure SwiftUI can properly manage its state.
  2. You no longer need to use PassthroughSubject directly. It's easier to use the @Published property wrapper instead.

That being said, here are a couple of code snippets that show how you can implement Email/Password Authentication with SwiftUI:

Main View

The main view shows if you're sign in. If you're not signed in, it will display a button that will open a separate sign in screen.

import SwiftUI

struct ContentView: View {
  @StateObject var viewModel = ContentViewModel()
  
  var body: some View {
    VStack {
      Text("👋🏻 Hello!")
        .font(.title3)
      
      switch viewModel.isSignedIn {
      case true:
        VStack {
          Text("You're signed in.")
          Button("Tap here to sign out") {
            viewModel.signOut()
          }
        }
      default:
        VStack {
          Text("It looks like you're not signed in.")
          Button("Tap here to sign in") {
            viewModel.signIn()
          }
        }
      }
    }
    .sheet(isPresented: $viewModel.isShowingLogInView) {
      SignInView()
    }
  }
}

The main view's view model listens for any auth state changes and updates the isSignedIn property accordingly. This drives the ContentView and what it displays.

import Foundation
import Firebase

class ContentViewModel: ObservableObject {
  @Published var isSignedIn = false
  @Published var isShowingLogInView = false
  
  init() {
    // listen for auth state change and set isSignedIn property accordingly
    Auth.auth().addStateDidChangeListener { auth, user in
      if let user = user {
        print("Signed in as user \(user.uid).")
        self.isSignedIn = true
      }
      else {
        self.isSignedIn = false
      }
    }
  }
  
  /// Show the sign in screen
  func signIn() {
    isShowingLogInView = true
  }
  
  /// Sign the user out
  func signOut() {
    do {
      try Auth.auth().signOut()
    }
    catch {
      print("Error while trying to sign out: \(error)")
    }
  }
}

SignIn View

The SignInView shows a simple email/password form with a button. The interesting thing to note here is that it listens for any changes to the viewModel.isSignedIn property, and calls the dismiss action (which it pulls from the environment). Another option would be to implement a callback as a trailing closure on the view model's signIn() method.

struct SignInView: View {
  @Environment(\.dismiss) var dismiss
  @StateObject var viewModel = SignInViewModel()
  
  var body: some View {
    VStack {
      Text("Hi!")
        .font(.largeTitle)
      Text("Please sign in.")
        .font(.title3)
      Group {
        TextField("Email", text: $viewModel.email)
          .disableAutocorrection(true)
          .autocapitalization(.none)
        SecureField("Password", text: $viewModel.password)
      }
      .padding()
      .background(Color(UIColor.systemFill))
      .cornerRadius(8.0)
      .padding(.bottom, 8)
      
      Button("Sign in") {
        viewModel.signIn()
      }
      .foregroundColor(Color(UIColor.systemGray6))
      .padding(.vertical, 16)
      .frame(minWidth: 0, maxWidth: .infinity)
      .background(Color.accentColor)
      .cornerRadius(8)
    }
    .padding()
    .onChange(of: viewModel.isSignedIn) { signedIn in
      dismiss()
    }
  }
}

The SignInViewModel has a method signIn that performs the actual sign in process by calling Auth.auth().signIn(withEmail:password:). As you can see, it will change the view model's isSignedIn property to true if the user was authenticated.

import Foundation
import FirebaseAuth

class SignInViewModel: ObservableObject {
  @Published var email: String = ""
  @Published var password: String = ""
  
  @Published var isSignedIn: Bool = false
  
  func signIn() {
    Auth.auth().signIn(withEmail: email, password: password) { authDataResult, error in
      if let error = error {
        print("There was an issue when trying to sign in: \(error)")
        return
      }
      
      guard let user = authDataResult?.user else {
        print("No user")
        return
      }
      
      print("Signed in as user \(user.uid), with email: \(user.email ?? "")")
      self.isSignedIn = true
    }
  }
}

Alternative: Using Combine

import Foundation
import FirebaseAuth
import FirebaseAuthCombineSwift

class SignInViewModel: ObservableObject {
  @Published var email: String = ""
  @Published var password: String = ""
  
  @Published var isSignedIn: Bool = false

  // ...

  func signIn() {
    Auth.auth().signIn(withEmail: email, password: password)
      .map { $0.user }
      .replaceError(with: nil)
      .print("User signed in")
      .map { $0 != nil }
      .assign(to: &$isSignedIn)
  }
}

Alternative: Using async/await

import Foundation
import FirebaseAuth

class SignInViewModel: ObservableObject {
  @Published var email: String = ""
  @Published var password: String = ""
  
  @Published var isSignedIn: Bool = false
  

  @MainActor
  func signIn() async {
    do {
      let authDataResult = try 3 await 1 Auth.auth().signIn(withEmail: email, password: password)
      let user = authDataResult.user
    
      print("Signed in as user \(user.uid), with email: \(user.email ?? "")")
      self.isSignedIn = true
    }
    catch {
      print("There was an issue when trying to sign in: \(error)")
      self.errorMessage = error.localizedDescription
    }
  }
}

More details

I wrote an article about this in which I explain the individual techniques in more detail: Calling asynchronous Firebase APIs from Swift - Callbacks, Combine, and async/await. If you'd rather watch a video, I've got you covered as well: 3 easy tips for calling async APIs

Upvotes: 3

Related Questions