swiftswiftuimvvm

Passing an instance of view model from environment to a child view's view model


I'm trying to learn MVVM architecture, and I stumbled upon a certain issue I would like to resolve. I have a MainView with tabs, this MainView has a view model which holds, among others, an array of custom data type variables called Event.

I want to have those events available throughout the whole app and I'm using an environment for that. And now to the core of my question - I have a HomePageView that has it's own view model, but for it to function properly (ie. when refreshing the array of Event) I need to utilize the MainViewModel and its methods. It has to be the same instance of the MainViewModel to ensure that it holds one source of truth, so the app works with only one set of Event. I have a working solution but I'm wondering if I'm doing it correctly.

More specifically, if it's OK to pass the MainViewModel or its data to every method separately or if there's a better way to maybe pass the whole MainViewModel to the HomePageViewModel so it has access to it directly?

My code looks like this - HomePageView:

import SwiftUI
import SwiftData
import MapKit

struct HomePageView: View {
    
    @EnvironmentObject var mainViewModel: MainViewViewModel
    @StateObject private var homePageViewModel = HomePageViewModel()
    @Environment(\.modelContext) var modelContext
    @Query private var savedEvents: [FavoriteEvent] = []
    @Environment(\.scenePhase) var scenePhase
    @State private var cameFromBackground = true
    
    var body: some View {
        NavigationStack {
            ScrollView {
                VStack {
                    
                    //MARK: saved/favorite events
                    HStack {
                        Text("Uložené akce")
                            .font(.title2.bold())
                        Spacer()
                    }
                    .padding([.top, .horizontal])
                    
                    // a fallback and a hint to help user discover the app's functions
                    if homePageViewModel.shouldShowNoEventsSavedView(savedEvents: savedEvents) {
                        ContentUnavailableView(
                            "Žádné uložené akce.",
                            systemImage: "calendar.badge.plus",
                            description: Text("Přejděte do záložky Akce a v detailu si přidejte svou první událost!")
                        )
                        .padding()
                    } else {
                        savedEventsView
                    }
                    
                    //MARK: current events
                    HStack {
                        Text("Právě se děje")
                            .font(.title2.bold())
                        Spacer()
                    }
                    .padding()
                    
                    
                    // a current events view with fallback options
                    switch mainViewModel.status {
                    case .notStarted:
                        ContentUnavailableView("Objevte nové akce.", systemImage: "magnifyingglass", description: Text("Potažením dolu obnovte stránku a objevte nové akce."))
                    case .fetching:
                        ProgressView()
                    case .success:
                        if homePageViewModel.shouldShowNoEventsHappeningView() {
                            ContentUnavailableView(
                                "Dnes se nekonají žádné akce",
                                systemImage: "calendar.badge.exclamationmark",
                                description: Text("Přejděte do záložky Akce a prohlédněte si nadcházející události!")
                            )
                            .padding()
                        } else {
                            todayEventsView
                        }
                        
                    case .failed:
                        ContentUnavailableView("Nepodařilo se najít žádné akce.", systemImage: "exclamationmark.magnifyingglass", description: Text("Bohužel se nepodařilo nalézt žádné akce. Zkuste obnovit hledání potažením dolu."))
                    }
                }
                .scrollIndicators(.hidden)
                .preferredColorScheme(.dark)
                .navigationTitle("Přehled")
            }
            .scrollIndicators(.hidden)
            .refreshable {
                Task {
                    await homePageViewModel.refreshView(with: mainViewModel)
                }
            }
        }
        // all events are loaded on appear only when there are none yet
        .onAppear() {
            if homePageViewModel.shouldLoadAllEvents(allEvents: mainViewModel.allEvents) {
                Task {
                    await homePageViewModel.refreshView(with: mainViewModel)
                }
            }
        }
    }

HomePageViewModel:

import Foundation
import SwiftUI
import SwiftData


class HomePageViewModel: ObservableObject {
    
    @Published var sortedAllEvents: [Event] = []
    @Published var todayEvents: [Event] = []
    
    // a method that filters sorted events and keeps only those that are happening today
    @MainActor func getTodayEvents(allEvents: [Event]) {
        var events: [Event] = []
        for event in allEvents {
            if isEventToday(eventProperties: event.properties) {
                events.append(event)
            }
        }
        let sortedEvents = events.sorted { $0.properties.dateFrom < $1.properties.dateFrom }
        todayEvents = sortedEvents
    }
    
    // a helper method that sorts events based on their starting date
    private func sortEventsByDate(fetchedEvents: FetchedEvents) {
        sortedAllEvents = fetchedEvents.features.sorted { $0.properties.dateFrom < $1.properties.dateFrom }
    }
    
    // a helper method used to refresh the page
    func refreshView(with mainViewModel: MainViewViewModel) async {
        Task {
            await mainViewModel.getAllEvents()
            await getTodayEvents(allEvents: mainViewModel.allEvents)
        }
    }
    
    // a simple method to determine whether to load events if there are none present
    @MainActor func shouldLoadAllEvents(allEvents: [Event]) -> Bool {
        return allEvents.isEmpty
    }
    
    func shouldShowNoEventsSavedView(savedEvents: [FavoriteEvent]) -> Bool {
        return savedEvents.isEmpty
    }
    
    func shouldShowNoEventsHappeningView() -> Bool {
        return todayEvents.isEmpty
    }
    
    
    // a helper method that returns true if the event is happening today
    private func isEventToday(eventProperties: EventDetails) -> Bool {
        
        let dateFrom = eventProperties.dateFrom / 1000
        let dateTo = eventProperties.dateTo / 1000
        let todayUnix = Date().timeIntervalSince1970
        
        if todayUnix >= dateFrom && todayUnix <= dateTo {
            return true
        } else {
            return false
        }
    }
}

Solution

  • In MVVM, a View and the VM have a 1:1 relationship. Also, a VM implements logic (ideally a pure function) and publishes view state. The logic and the view state logic is inherent to your use case and never changes. This means, there's never a reason one would want to inject a view model via passing it through the environment. What you may want to inject via an environment are actual dependencies that the model of the view model uses.

    Your app is a hierarchy of views, like a tree. You should employ a structured communication. You can also think of a view model being part of this hierarchy, actually a parent of its View. Actually, you can implement the ViewModel as a SwiftUI View and completely get rid of all Observables objects (but I'm digressing). That is, the parent sends state to its children views, and children send messages only to their parent via a closure.

    A child view has no reference to its parent view, instead a parent passes a closure to its children and those then can send messages to the parent.

    You should avoid having ViewModels or Views sending messages to arbitrary other views or view models. To reach a sibling, you need to send the message to the parent and the parent sends it to the other child.

    A ViewModel should not reference another ViewModel. If you do, it's not MVVM anymore. Certainly you can - but I would strongly discourage from it. ViewModel can send messages further downstream, which is its Model. The Modal can be a shared object. Thus, a ViewModel A can send a message to Model M, and M publishes "data" which changes due to processing the message received from VM A. Your ViewModel B will observe Model M as well and get notified, which in turn causes a reaction, based on its state and the event of the change from its model.

    A ViewModel mostly needs to account for a "state". Your view model does not. For example, one can call refreshView(with mainViewModel several times, and your view model will create a task each time. Keeping track of its running services is the main objectivity of a ViewModel. You need state in your ViewModel to account for this.