0

I have a view/view model that is supposed to show a current time period (a Season) based on a length of time (monthly or quarterly) specified by the user.

The flow to get this season is as follows:

  1. The seasonLength must be specified by the end user first. If none is found, they go to a screen where they pick monthly/quarterly.
  2. Once there is a seasonLength stored (may have already been in the users profile), the app looks for a season in the database that includes the current date.
  3. If none is found, it creates one.
  4. The app takes the season found in #2 or #3, stores it as the featuredSeason (and defaultSeason) in the viewModel, and that should display to the end user.

I use a property observer on seasonLength to trigger the flow to create the Season, and I use some Combine logic to respond to the season that is created/loaded by the repository.

The issue I'm running into is that my ViewModel is repeatedly running through the initialization methods, which is causing it to get stuck in an infinite loop of re-loading the Season.

My question is - what could lead to this ViewModel being recreated over and over again? (I think it's an issue with my logic, because I've been messing around with the downstream logic for steps 1-4 above, and sometimes this doesn't happen (although when it doesn't happen, the code flow hasn't worked properly)).

Here is the view that looks at the viewModel:

struct SeasonView: View {
    
    @EnvironmentObject var currentUser: CurrentUserProfile
    @ObservedObject var seasonGoalsVM = SeasonGoalsViewModel()
    
    var body: some View {
        if seasonGoalsVM.seasonLength == nil {
            ChooseSeasonLength()
                .environmentObject(seasonGoalsVM)
        } else if seasonGoalsVM.featuredSeason != nil {
            SeasonView()
                .environmentObject(seasonGoalsVM)
        } else {
            LoadingScreen()
                .environmentObject(seasonGoalsVM)
        }
    }
}

And here is the flow for creating:

First, the list of variables in my ViewModel, with the property observer whenever seasonLength is updated - this is supposed to make sure it's not set to nil, or just showing it's old value, and if not, it will add that seasonLength to the user's profile, and then load or create a current season in the season repository:

class SeasonGoalsViewModel: ObservableObject {
    @Published var seasonRepository: SeasonStoreType
    @Published var seasonLength: SeasonLength? {
        didSet {
            guard seasonLength != nil else {
                print("Season Length is nil - ending")
                return
            }
            
            guard seasonLength != oldValue else {
                print("Season Length was changed to the same value")
                return
            }
            
            if seasonLength != self.currentUser.currentUser?.seasonLength {
                self.currentUser.currentUser!.seasonLength = seasonLength
                self.currentUser.updateUser(self.currentUser.currentUser!)
            }
            
            guard self.defaultSeason == nil else {
                return
            }
            
            self.seasonRepository.loadOrCreateCurrentSeason()
        }
    }
    
    @Published var defaultSeason: Season?
    @Published var featuredSeason: Season?
    
    @Published var currentUser: CurrentUserType
    
    private var cancellables = Set<AnyCancellable>()

And here is the initializer for my viewModel - it sets the seasonLength to that currently specified in the user's profile, which should trigger this whole flow. (If one is not found, there is a sub-view to the view specified above that will allow the user to select a seasonLength):

    init(seasonRepository: SeasonStoreType = SeasonRepository(), currentUser: CurrentUserType = CurrentUserProfile.shared) {
        // self.goalRepository = GoalRepository()
        self.seasonRepository = seasonRepository
        self.currentUser = currentUser
        self.seasonLength = currentUser.currentUser?.seasonLength
        
        self.seasonRepository.currentSeasonPublisher
            .sink { [weak self] season in
                print("SeasonLength is \(String(describing: self?.seasonLength))")
                if season != nil {
                    self?.handleNewDefaultSeason(season!)
                }
            }
            .store(in: &cancellables)
    }

I don't know if this is necessary to include, but once a seasonLength is specified, it calls a method in the repository that loads or creates the current season based on a method loadMostRecentSeason() -> Future<Season, ErrorLoadingSeason>:

    func loadOrCreateCurrentSeason() {
        var _ = loadMostRecentSeason()
            .sink(receiveCompletion: { completion in
                switch completion {
                case .finished:
                    break
                case .failure(let error):
                    print(error)
                    self.addSeasonToSubcollection(season: Season(lastOrder: 0))
                }
            }, receiveValue: { season in
                    if season.endDate < Date() {
                        let oldOrder = season.order
                        self.currentSeason = self.createCurrentSeason(order: oldOrder)
                        self.addSeasonToSubcollection(season: Season(lastOrder: oldOrder))
                    } else {
                        self.currentSeason = season
                    }
                })
            .store(in: &cancellables)
    }

That code should set the self.seasonRepository.currentSeasonPublisher that I am subscribed to in the init of the viewModel, which triggers this "handleNewDefaultSeason" code, which sets the defaultSeason/featuredSeason to the season identified in the repository, and makes sure the user's profile is updated to match that:

    func handleNewDefaultSeason(_ season: Season) {
        let seasonId = season.id
        let userProfileDefaultSeason = currentUser.currentUser?.defaultSeason
        
        print("season ID is equal to \(String(describing: seasonId))")
        print("userProfileDefaultSeasonId is equal to \(String(describing: userProfileDefaultSeason))")
        print("SeasonLength is \(String(describing: seasonLength))")
        
        
        // Sets the default season in the view model to default.
        self.defaultSeason = season
        self.featuredSeason = season
        guard seasonId != userProfileDefaultSeason else {
            print("Season ID of \(String(describing: seasonId)) is already equal to user profile default season: \(String(describing: userProfileDefaultSeason))")
            return
        }
        guard self.currentUser.currentUser != nil else {
            fatalError("there is no current user")
        }
        
        self.currentUser.currentUser!.defaultSeason = seasonId
        self.currentUser.updateUser(self.currentUser.currentUser!)
    }

It seems to work all the way through, but I think something in here is leading the initializer to be re-run repeatedly, and I'm not sure what's causing that. Are there any common issues that could lead to something like this?

Charlie Page
  • 541
  • 2
  • 17
  • The title of the question is different, but the issue is exactly the same as in the linked duplicate target. You should never be initialising an `@ObservedObject` inside the view. – Dávid Pásztor Jun 18 '21 at 13:13
  • Use `@StateObject var seasonGoalsVM = SeasonGoalsViewModel()` instead it does it because it is a `struct` and SwiftUI has discretion on when to reinitialize the `View` – lorem ipsum Jun 18 '21 at 19:04

0 Answers0