Search code examples
mvvmswiftuicombineobservableobjectenvironmentobject

Timer within EnvironmentObject view model not updating the View


I have a view model, that has multiple child view models. I am fairly new to watchOS, SwiftUI and Combine - taking this opportunity to learn.

I have a watchUI where it has

  1. Play Button (View) - SetTimerPlayPauseButton
  2. Text to show Time (View) - TimerText
  3. View Model - that has 1 WatchDayProgramViewModel - N: ExerciseTestClass - N: SetInformationTestClass. For each ExerciseSets, there is a watchTimer & watchTimerSubscription and I have managed to run the timer to update remaining rest time.
  4. ContentView - that has been injected the ViewModel as EnvironmentObject

If I tap SetTimerPlayPauseButton to start the timer, timer is running, working and changing the remainingRestTime(property within the child view model SetInformationTestClass) correctly, but the updates/changes are not being "published" to the TimerText View.

I have done most, if not all, the recommendation in other SO answers, I even made all my WatchDayProgramViewModel and ExerciseTestClass,SetInformationTestClass properties @Published, but they are still not updating the View, when the view model properties are updated as shown in the Xcode debugger below.

enter image description here

Please review my code and give me some advice on how to improve it.

ContentView

struct ContentView: View {
    @State var selectedTab = 0
    @StateObject var watchDayProgramVM = WatchDayProgramViewModel()
    
    var body: some View {
        
        TabView(selection: $selectedTab) {
            
            SetRestDetailView().id(2)
    
        }
        .environmentObject(watchDayProgramVM)
        .tabViewStyle(PageTabViewStyle())
        .indexViewStyle(.page(backgroundDisplayMode: .automatic))
        
    }
}

    
struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        Group {
            ContentView(watchDayProgramVM: WatchDayProgramViewModel())
        }
    }
}

SetRestDetailView

import Foundation
import SwiftUI
import Combine

struct SetRestDetailView: View {
    
    @EnvironmentObject var watchDayProgramVM: WatchDayProgramViewModel
    
    var setCurrentHeartRate: Int = 120
    @State var showingLog = false
    
    
    var body: some View {


                    HStack {
         
                        let elapsedRestTime = watchDayProgramVM.exerciseVMList[0].sets[2].elapsedRestTime
                        let totalRestTime = watchDayProgramVM.exerciseVMList[0].sets[2].totalRestTime
                        
                        TimerText(elapsedRestTime: elapsedRestTime, totalRestTime: totalRestTime, rect: rect)
                            .border(Color.yellow)

                    }
                    
                    HStack {
                        
                        SetTimerPlayPauseButton(isSetTimerRunningFlag: false,
                                                playImage: "play.fill",
                                                pauseImage: "pause.fill",
                                                bgColor: Color.clear,
                                                fgColor: Color.white.opacity(0.5),
                                                rect: rect) {
                            
                            print("playtimer button tapped")
                            self.watchDayProgramVM.exerciseVMList[0].sets[2].startTimer()
                            
                            
                            let elapsedRestTime = watchDayProgramVM.exerciseVMList[0].sets[2].elapsedRestTime
                            let totalRestTime = watchDayProgramVM.exerciseVMList[0].sets[2].totalRestTime
                            print("printing elapsedRestTime from SetRestDetailView \(elapsedRestTime)")
                            print("printing elapsedRestTime from SetRestDetailView \(totalRestTime)")
                            
                        }
                            .border(Color.yellow)

                    }

 }

}

TimerText

struct TimerText: View {
    var elapsedRestTime: Int
    var totalRestTime: Int
    var rect: CGRect
    
    var body: some View {
        VStack {
            Text(counterToMinutes())
                .font(.system(size: 100, weight: .semibold, design: .rounded))
                .kerning(0)
                .fontWeight(.semibold)
                .minimumScaleFactor(0.25)
                .padding(-1)
        }
    }
    
    func counterToMinutes() -> String {
        let currentTime = totalRestTime - elapsedRestTime
        let seconds = currentTime % 60
        let minutes = Int(currentTime / 60)
        
        if currentTime > 0 {
            return String(format: "%02d:%02d", minutes, seconds)
        }
        
        else {
            return ""
        }
    }
}

ViewModel

import Combine

final class WatchDayProgramViewModel: ObservableObject {
    
    @Published var exerciseVMList: [ExerciseTestClass] = [
 (static/hard-coded values for testing)
]

class ExerciseTestClass: ObservableObject {
    
    init(exercise: String, sets: [SetInformationTestClass]) {
        
        self.exercise = exercise
        self.sets = sets
        
    }
    
        var exercise: String
        @Published var sets: [SetInformationTestClass]
    
    }

class SetInformationTestClass: ObservableObject {
    
    init(totalRestTime: Int, elapsedRestTime: Int, remainingRestTime: Int, isTimerRunning: Bool) {
        
        self.totalRestTime = totalRestTime
        self.elapsedRestTime = elapsedRestTime
        self.remainingRestTime = remainingRestTime
        self.isTimerRunning = isTimerRunning
        
    }
    
    @Published var totalRestTime: Int
    @Published var elapsedRestTime: Int
    @Published var remainingRestTime: Int
    
    @Published var isTimerRunning = false
    @Published var watchTimer = Timer.publish(every: 1.0, on: .main, in: .default)
    @Published var watchTimerSubscription: AnyCancellable? = nil
    
    @Published private var startTime: Date? = nil
    
    
    func startTimer() {
        
        print("startTimer initiated")
        self.watchTimerSubscription?.cancel()
        
        if startTime == nil {
            startTime = Date()
        }
        
        self.isTimerRunning = true
        
        self.watchTimerSubscription = watchTimer
            .autoconnect()
            .sink(receiveValue: { [weak self] _ in
                
                guard let self = self, let startTime = self.startTime else { return }
                
                let now = Date()
                let elapsedTime = now.timeIntervalSince(startTime)
                
                self.remainingRestTime = self.totalRestTime - Int(elapsedTime)
                
                self.elapsedRestTime = self.totalRestTime - self.remainingRestTime
                                                    
                guard self.remainingRestTime > 0 else {
                        self.pauseTimer()
                        return
                    }

self.objectWillChange.send()
                
                print("printing elapsedRest Time \(self.elapsedRestTime) sec")
                print("printing remaining Rest time\(self.remainingRestTime)sec ")
                
            })
    }
    
    func pauseTimer() {
        //stop timer and retain elapsed rest time
        
        print("pauseTimer initiated")
        self.watchTimerSubscription?.cancel()
        self.watchTimerSubscription = nil
        self.isTimerRunning = false
        self.startTime = nil
        
    }
    
        

Solution

  • Managed to resolve the issue with help of @lorem ipsum and his feedback. As per his comment, the problem lied with the fact that

    it is more than likely not working because you are chaining ObservableObjects @Published will only detect a change when the object is changed as a whole now when variables change. One way to test is to wrap each SetInformationTestClass in an @ObservbleObject by using a subview that takes the object as a parameter.

    After which, I managed to find similar SO answers on changes in nested view model (esp child), and made the child view model an ObservedObject. The changes in child view model got populated to the view. Please see the changed code below.

    SetRestDetailView

    import Foundation
    import SwiftUI
    import Combine
    
    struct SetRestDetailView: View {
        
        @EnvironmentObject var watchDayProgramVM: WatchDayProgramViewModel
        
        var setCurrentHeartRate: Int = 120
        @State var showingLog = false
        
        
        var body: some View {
    
    
                        HStack {
             
                            let elapsedRestTime = watchDayProgramVM.exerciseVMList[0].sets[2].elapsedRestTime
                            let totalRestTime = watchDayProgramVM.exerciseVMList[0].sets[2].totalRestTime
                            
                            let setInformatationVM = self.watchDayProgramVM.exerciseVMList[0].sets[2]
                            
                            TimerText(setInformationVM: setInformatationVM, rect: rect)
                                .border(Color.yellow)
    
                        }
                        
                        HStack {
                            
                            SetTimerPlayPauseButton(isSetTimerRunningFlag: false,
                                                    playImage: "play.fill",
                                                    pauseImage: "pause.fill",
                                                    bgColor: Color.clear,
                                                    fgColor: Color.white.opacity(0.5),
                                                    rect: rect) {
                                
                                print("playtimer button tapped")
                                self.watchDayProgramVM.exerciseVMList[0].sets[2].startTimer()
                                
                                
                                let elapsedRestTime = watchDayProgramVM.exerciseVMList[0].sets[2].elapsedRestTime
                                let totalRestTime = watchDayProgramVM.exerciseVMList[0].sets[2].totalRestTime
                                print("printing elapsedRestTime from SetRestDetailView \(elapsedRestTime)")
                                print("printing elapsedRestTime from SetRestDetailView \(totalRestTime)")
                                
                            }
                                .border(Color.yellow)
    
                        }
    
     }
    
    }
    

    TimerText

    struct TimerText: View {
        
        @ObservedObject var setInformationVM: SetInformationTestClass
        
    //    @State var elapsedRestTime: Int
    //    @State var totalRestTime: Int
        var rect: CGRect
        
        var body: some View {
            VStack {
                Text(counterToMinutes())
                    .font(.system(size: 100, weight: .semibold, design: .rounded))
                    .kerning(0)
                    .fontWeight(.semibold)
                    .minimumScaleFactor(0.25)
                    .padding(-1)
            }
        }
        
        func counterToMinutes() -> String {
            let currentTime = setInformationVM.totalRestTime - setInformationVM.elapsedRestTime
            let seconds = currentTime % 60
            let minutes = Int(currentTime / 60)
            
            if currentTime > 0 {
                return String(format: "%02d:%02d", minutes, seconds)
            }
            
            else {
                return ""
            }
        }
    }