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.