skip to Main Content

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


  1. 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.

    public protocol InjectionKey {
    
        /// The associated type representing the type of the dependency injection key's value.
        associatedtype Value
    
        /// The default value for the dependency injection key.
        static var currentValue: Self.Value { get set }
    }
    private struct NetworkProviderKey: InjectionKey {
        static var currentValue: NetworkManagerProtocol = FirebaseNetworkManager() //Shared value 
    }
    extension InjectedValues {
        var networkManager: NetworkManagerProtocol {
            get { Self[NetworkProviderKey.self] }
            set { Self[NetworkProviderKey.self] = newValue }
        }
    }
    /// Provides access to injected dependencies.
    struct InjectedValues {
        
        /// This is only used as an accessor to the computed properties within extensions of `InjectedValues`.
        private static var current = InjectedValues()
        
        /// A static subscript for updating the `currentValue` of `InjectionKey` instances.
        static subscript<K>(key: K.Type) -> K.Value where K : InjectionKey {
            get { key.currentValue }
            set { key.currentValue = newValue }
        }
        
        /// A static subscript accessor for updating and references dependencies directly.
        static subscript<T>(_ keyPath: WritableKeyPath<InjectedValues, T>) -> T {
            get { current[keyPath: keyPath] }
            set { current[keyPath: keyPath] = newValue }
        }
    }
    @propertyWrapper
    struct Injected<T> {
        private let keyPath: WritableKeyPath<InjectedValues, T>
        var wrappedValue: T {
            get { InjectedValues[keyPath] }
            set { InjectedValues[keyPath] = newValue }
        }
        
        init(_ keyPath: WritableKeyPath<InjectedValues, T>) {
            self.keyPath = keyPath
        }
    }
    

    The NetworkManagerProtocol is where you will define what your Manager’s are going to share with the View Models

    protocol NetworkManagerProtocol {
        // Define names publisher, It would be so much easier if @Published could be used here but we have to make do with this.
        var namesPublisher: Published<[String]>.Publisher { get }
        func addNewName(name: String)
    }
    

    Then you can have your manager with all the pertinent code.

    class FirebaseNetworkManager: NetworkManagerProtocol {
        //This does not have a two-way connection with Managers
        @Published private (set) var names: [String] = []
    
        var namesPublisher: Published<[String]>.Publisher { $names }
        
        func addNewName(name: String) {
            names.append(name)
        }
    }
    

    Then your ViewModels would subscribe to the Manager’s publisher.

    class FirstVM: ObservableObject {
        @Injected(.networkManager) private var networkManager
        //This does not have a two-way connection to the Manager
        @Published private (set) var names: [String] = []
        private var cancellables: Set<AnyCancellable> = []
    
        init() {
            //Subscribe to the shared publisher
            networkManager.namesPublisher
                       .assign(to: .names, on: self)
                       .store(in: &cancellables)
        }
        
        func addName() {
            print("(type(of: networkManager))")
            networkManager.addNewName(name: UUID().uuidString)
        }
    }
    
    class SecondVM: ObservableObject {
        @Injected(.networkManager) private var networkManager
        //This does not have a two-way connection to the Manager
        @Published private (set) var names: [String] = []
        private var cancellables: Set<AnyCancellable> = []
        init() {
            //Subscribe to the shared publisher
            networkManager.namesPublisher
                       .assign(to: .names, on: self)
                       .store(in: &cancellables)
        }
        func addName() {
            print("(type(of: networkManager))")
    
            networkManager.addNewName(name: UUID().uuidString)
        }
    }
    

    Then your SwiftUI.View can just use the ViewModels as usual.

    import SwiftUI
    import Combine
    struct SwiftUIDISample: View {
        @StateObject var vmOne: FirstVM = .init()
        @StateObject var vmTwo: SecondVM = .init()
    
        init() {
            #if DEBUG
            //Must be called before the "subscribe" part of the VMs
            InjectedValues[.networkManager] = MockedNetworkManager()
            #endif
        }
        var body: some View {
            Form {
                Section {
                    Button("add 1") {
                        
                        vmOne.addName()
                    }
                    List(vmOne.names, id:.description) { name in
                        Text(name)
                    }
                } header: {
                    Text("First")
                }
                Section {
                    Button("add 2") {
                        vmTwo.addName()
                    }
                    List(vmTwo.names, id:.description) { name in
                        Text(name)
                    }
                } header: {
                    Text("Second")
                }
            }
        }
    }
    
    struct SwiftUIDISample_Previews: PreviewProvider {
        static var previews: some View {
            SwiftUIDISample()
        }
    }
    

    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 for Previews or tests without incurring Firebase costs.

    class MockedNetworkManager: NetworkManagerProtocol {
        //This does not have a two-way connection with Managers
        @Published private (set) var names: [String] = []
    
        var namesPublisher: Published<[String]>.Publisher { $names }
    
        init() {
            //Add Mock data
            (0...1).forEach { n in
                names.append(n.description)
            }
        }
        func addNewName(name: String) {
            names.append(name)
        }
    }
    

    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.

    Login or Signup to reply.
  2. 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.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search