简体   繁体   中英

Setting @Published var in Firebase Completion Block Not Updating SwiftUI View

I am currently running into an issue that is confusing me. I am trying to create a user using Firebase for my app, which is a SwiftUI app. I have a UserDataController that holds onto a @Published var profile: Profile? variable.

What I am noticing is that after creating a Profile in Firebase I get the callback with the data and decode it into my model. I then set that decoded model on my published property. However, when I do that the SwiftUI view does not change like I expect it to.

I have tested this functionality before introducing Firebase with test data. When I set the published property with the test data I do see the SwiftUI views update accordingly. I see that happen even when I update the published property in a DispatchQueue.main.asyncAfter block to simulate a network request.

Am I doing something wrong that is not allowing SwiftUI to update?

Also note that I am using Resolver for my UserDataController injection. The @InjectedObject grabs an @ObservedObject for use in SwiftUI views.

Here is my code:

App.swift

import Resolver
import SwiftUI

@main
struct MyApp: App {

    @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate

    @InjectedObject var userController: UserDataController

    init() {
          // This is the method that calls `DispatchQueue.main.asyncAfter` which causes the
          // view to update correctly.
//        userController.authenticate()
    }

    var body: some Scene {
        WindowGroup {
            // This is where SwiftUI should be updating to show the profile instead of 
            // the LandingView since we have been logged in.
            if let profile = userController.profile {
                ProfileView()
                    .environmentObject(ProfileViewModel(profile: profile))
            } else {
                // This is where the login form is
                LandingView()
            }
        }
    }
}

UserDataController.swift

import Firebase
import FirebaseAuth
import FirebaseFirestoreSwift
import Foundation

// This AuthError also will not show as an alert when set from a completion block
enum AuthError: Error, Identifiable {

    var id: AuthError { self }

    case noUser
    case emailExists
    case couldNotSignOut
    case generic
}

final class UserDataController: ObservableObject {

    @Published var profile: Profile? {
        didSet {
            print("Profile: \(profile)")
        }
    }
    @Published var user: User?

    @Published var authError: AuthError?

    private lazy var db = Firestore.firestore()

    private var authStateListener: AuthStateDidChangeListenerHandle?
    private var profileListener: ListenerRegistration?

    // MARK: Auth

    func authenticate() {
        DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
            self.profile = TestData.amyAlmond
        }
    }

    func login(email: String, password: String) {
        applyStateListener()

        Auth.auth().signIn(withEmail: email, password: password) { [weak self] result, error in
            if let error = error {
                self?.authError = .generic
            } else if let user = result?.user {
                self?.addSnapshotListener(for: user)
            } else {
                self?.authError = .noUser
            }
        }
    }

    func signUp(email: String, password: String, firstName: String, lastName: String) {
        applyStateListener()

        Auth.auth().createUser(withEmail: email, password: password) { [weak self] result, error in
            if let error = error {
                self?.authError = .generic
            } else if let user = result?.user {
                self?.addSnapshotListener(for: user)
                self?.createProfile(for: user, firstName: firstName, lastName: lastName)
            } else {
                self?.authError = .noUser
            }
        }
    }
}

// MARK: - Private

private extension UserDataController {

    func applyStateListener() {
        guard authStateListener == nil else { return }

        authStateListener = Auth.auth().addStateDidChangeListener { [weak self] auth, user in
            guard let self = self else { return }

            if let user = auth.currentUser {
                self.user = user
            } else {
                self.user = nil
                self.profile = nil

                self.profileListener?.remove()
                self.profileListener = nil

                if let stateListener = self.authStateListener {
                    Auth.auth().removeStateDidChangeListener(stateListener)
                    self.authStateListener = nil
                }
            }
        }
    }

    func addSnapshotListener(for user: User) {
        guard profileListener == nil else { return }

        profileListener = db.collection("profiles").document(user.uid).addSnapshotListener { [weak self] snapshot, error in
            guard let self = self else { return }
            guard let snapshot = snapshot else { return }

            do {
                // Setting the profile here does not change the SwiftUI view
                // These blocks happen on the main thread as well, so wrapping this
                // in a `DispatchQueue.main.async` does nothing.
                self.profile = try snapshot.data(as: Profile.self)
            } catch {
                print("Error Decoding Profile: \(error)")
            }
        }
    }

    func createProfile(for user: User, firstName: String, lastName: String) {
        let profile = Profile(uid: user.uid, firstName: firstName, lastName: lastName, farms: [], preferredFarmId: nil)

        do {
            try db.collection("profiles").document(user.uid).setData(from: profile)
        } catch {
            print(error)
        }
    }
}

LandingView.swift

import Resolver
import SwiftUI

struct LandingView: View {

    @InjectedObject private var userController: UserDataController

    var body: some View {
        VStack(spacing: 10) {
            LightText("Title")
                .font(.largeTitle)

            Spacer()

            AuthenticationView()

            Spacer()
        }
        .frame(maxWidth: .infinity)
        .padding()
        .alert(item: $userController.authError) { error -> Alert in
            Alert(title: Text("Oh Boy"), message: Text("Something went wrong"), dismissButton: .cancel())
        }
    }
}

AuthenticationView.swift

import SwiftUI

struct AuthenticationView: View {

    @StateObject private var viewModel = AuthenticationViewModel()

    var body: some View {
        VStack {
            VStack {
                Group {
                    switch viewModel.mode {
                    case .login:
                        loginForm
                    case .signUp:
                        signUpForm
                    }
                }
                .textFieldStyle(RoundedBorderTextFieldStyle())
            }
            .padding()
            .background(
                RoundedRectangle(cornerRadius: 20)
                    .foregroundColor(Color.gray)
            )

            Button(action: viewModel.switchMode) {
                Text(viewModel.switchModeTitle)
            }
            .padding(.bottom, 10)

            Button(action: viewModel.submitAction) {
                Text(viewModel.submitButtonTitle)
            }
            .disabled(!viewModel.isValid)
        }
        .padding()
    }
}

private extension AuthenticationView {

    @ViewBuilder
    var loginForm: some View {
        TextField("Email Address", text: $viewModel.emailAddress)
        TextField("Password", text: $viewModel.password)
    }

    @ViewBuilder
    var signUpForm: some View {
        TextField("First Name", text: $viewModel.firstName)
        TextField("Last Name", text: $viewModel.lastName)
        TextField("Email Address", text: $viewModel.emailAddress)
        TextField("Password", text: $viewModel.password)
        TextField("Confirm Password", text: $viewModel.confirmPassword)
    }
}

AuthenticationViewModel.swift

import Foundation
import Resolver

final class AuthenticationViewModel: ObservableObject {

    @Injected private var userController: UserDataController

    enum Mode {
        case login, signUp
    }

    @Published var firstName: String = ""
    @Published var lastName: String = ""
    @Published var emailAddress: String = ""
    @Published var password: String = ""
    @Published var confirmPassword: String = ""

    @Published var mode: Mode = .login
}

extension AuthenticationViewModel {

    var isValid: Bool {
        switch mode {
        case .login:
            return !emailAddress.isEmpty && isPasswordValid
        case .signUp:
            return !firstName.isEmpty
                && !lastName.isEmpty
                && !emailAddress.isEmpty
                && isPasswordValid
                && !confirmPassword.isEmpty
                && password == confirmPassword
        }
    }

    var submitButtonTitle: String {
        switch mode {
        case .login:
            return "Login"
        case .signUp:
            return "Create Account"
        }
    }

    var switchModeTitle: String {
        switch mode {
        case .login:
            return "Create a New Account"
        case .signUp:
            return "Login"
        }
    }

    func switchMode() {
        if mode == .login {
            mode = .signUp
        } else {
            mode = .login
        }
    }

    func submitAction() {
        switch mode {
        case .login:
            loginUser()
        case .signUp:
            createUser()
        }
    }
}

private extension AuthenticationViewModel {

    var isPasswordValid: Bool {
        !password.isEmpty && password.count > 8
    }

    func loginUser() {
        userController.login(email: emailAddress, password: password)
    }

    func createUser() {
        userController.signUp(email: emailAddress, password: password, firstName: firstName, lastName: lastName)
    }
}

I found the reason why it was not updating and it did not have to do with the setup above.

This is the first time I am using Resolver for dependency injection and I naively thought that registering an object made one instance. The reason my SwiftUI view was not updating is because I had two different instances of UserDataController and the one that set the profile was not the one in the SwiftUI view.

I fixed it by using the .scope(.application) function when registering my UserDataController with Resolver . That scope makes it act like a Singleton which is what I was going for in the first place.

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM