How to initialize view models after a singleton in swiftUI? I have an app in swiftUI. There are two views: ClassPosts View, where users can see posts made in their class at university, and a use profile view, where users can see their profile and posts they have made. These are both within a tab view. In the tab view I initialize the view models as state objects, and pass them into both views as environment objects. I know it is standard practice to have one view per view model, but that is not the basis of my question.I have a singleton, UserMananger, that holds the user information as a userDocument. In the UserManager, on the init, I make a call to firebase to get the user document, and set the properties of the userDocument to the returned values. After this returns, I need to be able to initialize the view models based on the UserManager Singleton. Getting posts for the user depends on the information in the singleton.
I tried making the userDocument property in the UserManger singleton @Published, but this did not fix the problem because the call to get the documents from firebase only happens once, while the user document has not yet returned. More broadly, I do not know if there is a better way to structure this. I asked ChatGpt4 if I could have a userDocument property in each of the view models, used the Auth.auth().currentUser? in the view models to get the email address, and make a call to get the user document in each view model, but it gave reasons why that is a bad idea, because it could lead to inconsistencies. Below is my current code. Note: The view router is what I use to go between views on the onboarding, it isn’t relevant here, and is not causing the issue.
The TabView:
struct InAppView: View {
@StateObject var viewRouter: ViewRouter
@StateObject var userProfile: UserProfileViewModel = UserProfileViewModel()
@StateObject var posts: ClassPostsViewModel = ClassPostsViewModel()
let userManger: UserManager = UserManager()
let customAccentColor = Color(red: 75 / 255, green: 175 / 255, blue: 210 / 255)
var body: some View {
TabView() {
ClassPosts(viewRouter: viewRouter)
.environmentObject(userProfile)
.environmentObject(posts)
.tabItem {
VStack {
Image(systemName: "list.bullet")
Text("Posts")
}
}
.tag(1)
UserProfileView(viewRouter: viewRouter)
.environmentObject(userProfile)
.environmentObject(posts)
.tabItem {
VStack {
Image(systemName: "person.fill")
Text("Profile")
}
}
.tag(0)
}
.accentColor(customAccentColor)
.onAppear {
let uiColor = UIColor(red: 75 / 255, green: 175 / 255, blue: 210 / 255, alpha: 1.0)
UITabBar.appearance().isTranslucent = true
UITabBar.appearance().backgroundImage = UIImage()
UITabBar.appearance().shadowImage = UIImage()
UITabBar.appearance().unselectedItemTintColor = UIColor.systemGray3
UITabBar.appearance().tintColor = uiColor
}
}
}
The UserManager Singleton:
class UserManager: ObservableObject {
static var shared: UserManager? = nil
let db = Firestore.firestore()
@Published var currentUser: UserDocument?
let firebaseManager = FirestoreService()
init() {
self.getDocument(user:self.getCurrentUserEmail()){ doc in
self.currentUser = doc
}
}
private func getCurrentUserEmail() -> String {
let currentUser = Auth.auth().currentUser
return currentUser?.email ?? ""
}
func getDocument(user: String, completion: @escaping (UserDocument?) -> Void) {
guard !user.isEmpty else {
completion(nil)
return
}
let doc = db.collection("Users").document(user)
doc.getDocument { documentSnapshot, error in
if let error = error {
print("Error fetching document: (error)")
completion(nil)
return
}
guard let data = documentSnapshot?.data(),
let firstName = data["FirstName"] as? String,
let lastName = data["LastName"] as? String,
let college = data["College"] as? String,
let birthday = data["Birthday"] as? String,
let major = data["Major"] as? String,
let classes = data["Classes"] as? [String],
let email = data["Email"] as? String,
let profilePictureURL = data["profile_picture"] as? String
else {
print("Invalid document data or missing fields")
completion(nil)
return
}
let retrievedDoc = UserDocument(FirstName: firstName, LastName: lastName, College: college, Birthday: birthday, Major: major, Classes: classes, Email: email, profilePictureURL: profilePictureURL)
completion(retrievedDoc)
}
}
}
The important parts of the ClassPost view Model related to initalization:
class ClassPostsViewModel: ObservableObject {
@Published var postsArray: [ClassPost] = []
@Published var repliesArray: [Reply] = []
@Published var curError: String = ""
@Published var isLoading: Bool = false
@Published var selectedClass: String = ""
let firebaseManager = FirestoreService()
let db = Firestore.firestore()
init() {
self.getPosts()
}
func getPosts() {
guard !self.selectedClass.isEmpty, let college = UserManager.shared?.currentUser?.College else {
print("Error: Class or college is undefined")
return
}
firebaseManager.fetchPosts(fromClass: self.selectedClass, fromCollege: college) { [weak self] (posts, error) in
if let error = error {
self?.curError = "Something went wrong getting the posts for (self?.selectedClass ?? "this class")"
return
}
self?.postsArray = posts ?? []
}
}
}
The relevant parts of the UserProfileViewModel:
class UserProfileViewModel: ObservableObject {
@Published var usersPosts: [ClassPost] = []
@Published var curError:String = ""
let db = Firestore.firestore()
let firebaseManager = FirestoreService()
func getPostsForUser() {
guard let college = UserManager.shared?.currentUser?.College else {
return
}
firebaseManager.getPostsForUser(college: college) { [weak self] posts, error in
guard let self = self else {
return
}
if let error = error {
// Handle the error
self.curError = "Something went wrong getting your posts. Try Again."
} else {
self.usersPosts = posts ?? []
// Perform any additional operations with the updated userPosts
}
}
}
init() {
self.getPostsForUser()
}
}
My main goal is trying to initialize the UserManger before I initialize the view models. I only want one instance of the UserManager. Thinking about this now, would I be able to initialize the Usermanger with a completion handler inside the .onAppear of the tabview, and then initialize the view models within the closure? Any help on code or program structure would be appreciated. Thanks
2
Answers
I’ll start with my approach uses Dependency Injection largely taken from here with the difference that I use Publishing variables that connect to SwiftUI via Combine’s
assign
.The code in the next section will seem like a lot but it is mostly copy and paste and you can read more about the details in the comments/link above.
The
NetworkManagerProtocol
is where you will define what your Manager’s are going to share with the View ModelsThen you can have your manager with all the pertinent code.
Then your ViewModels would subscribe to the Manager’s publisher.
Then your
SwiftUI.View
can just use theViewModel
s as usual.Now you’ll notice in the code above that I used
MockedNetworkManager
this is an added bonus with this setup, you can now create a class that you can use forPreviews
or tests without incurring Firebase costs.It might seem like a lot but if you copy and paste all this code into a .swift file you can see how it all connects.
If you use the new .task modifier you can use firebase via async/await and then you don’t even need those objects and will simplify the code.
Make your firebase controller a custom EnvironmentKey.