0

Im new to Swift and Im trying to create an workout WatchApp, I have 2 pages, the initial page that will show the last activity and a button to start workout, and I have another page that will show the current workout.

And I created a WorkoutSession class with NSObject, HKLiveWorkoutBuilderDelegate, HKWorkoutSessionDelegate, ObservableObject and get the attributes that I need, and when click in start workout, will call a function named startWorkout to begin collection datas, follow code:

ContentView (Main view):

import SwiftUI
import HealthKit

struct ContentView: View {
    var body: some View {
        NavigationView {
            VStack(alignment: .leading) {
                VStack(alignment: .leading) {
                    Text("Última atividade")
                        .padding(.bottom, 12)
                    
                    HStack {
                        
                        CaloriesRingView(
                            calories: 168
                        )
                        .padding(.trailing, 24)
                        
                        DurationRingView(
                            duration: 5400
                        )
                    }
                    
                    
                }.padding(.top, 12)
                
                NavigationLink(destination: WorkoutView()) {
                    Text("Começar treino")
                }
                .background(.green)
                .cornerRadius(20)
                .padding(.top, 24)
            }
            .padding(.horizontal, 12)
        }
        .navigationBarBackButtonHidden(true)
    }
}

Workout View:


import SwiftUI

struct WorkoutView: View {
    @ObservedObject var workoutSession = WorkoutSession()
    
    init() {
        workoutSession.setUpSession()
        workoutSession.startWorkoutSession()
    }
    
    var body: some View {
        NavigationView {
            if workoutSession.status == .inProgress {
                if workoutSession.bpm != nil {
                    Text(heartBeatFormatter())
                }
                
                if workoutSession.energyBurned != nil {
                    Text("\(workoutSession.energyBurned!) kcal")
                }
                
                if workoutSession.elapsedTime != nil {
                    Text("\(workoutSession.elapsedTime!)")
                }
            }
        }
        .navigationBarBackButtonHidden(true)

    }
    
    func heartBeatFormatter() -> String {
        let formatter = NumberFormatter()
        formatter.maximumSignificantDigits = 0
        return formatter.string(from: workoutSession.bpm! as NSNumber) ?? ""
    }
}

And my WorkoutSession:


import Foundation
import HealthKit
import SwiftUI

enum WorkoutSessionStatus {
    case inProgress, complete, cancelled, notStarted, paused
}

class WorkoutSession: NSObject, HKLiveWorkoutBuilderDelegate, HKWorkoutSessionDelegate, ObservableObject {
    @Published var energyBurnedStatistics: HKStatistics?;
    @Published var heartRateStatistics: HKStatistics?;
    @Published var elapsedTimeStatistics: HKStatistics?;
    
    @Published var energyBurned: Double?;
    @Published var bpm: Double?;
    @Published var elapsedTime: TimeInterval?;
    @Published var status = WorkoutSessionStatus.notStarted;
    @Published var workoutData: HKWorkout?
    
    @Published var session: HKWorkoutSession?;
    @Published var builder: HKLiveWorkoutBuilder?;
    
    var healthStore = HKHealthStore();
    
    func workoutBuilder(_ workoutBuilder: HKLiveWorkoutBuilder, didCollectDataOf collectedTypes: Set<HKSampleType>) {
        self.heartRateStatistics = workoutBuilder.statistics(for: .init(.heartRate))
        
        self.energyBurned = workoutBuilder.statistics(for: .init(.activeEnergyBurned))?.sumQuantity()?.doubleValue(for: .kilocalorie())
        self.energyBurnedStatistics = workoutBuilder.statistics(for: .init(.activeEnergyBurned))
        
        self.elapsedTime = workoutBuilder.elapsedTime
        
        self.bpm = calculateBPM()
    }
    
    func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) {
        
    }
    
    func workoutSession(_ workoutSession: HKWorkoutSession, didChangeTo toState: HKWorkoutSessionState, from fromState: HKWorkoutSessionState, date: Date) {
    
    }
    
    func workoutSession(_ workoutSession: HKWorkoutSession, didFailWithError error: Error) {
        
    }
    
    func setUpSession() {
        let typesToShare: Set<HKWorkoutType> = [HKQuantityType.workoutType()]
        
        let typesToRead: Set<HKQuantityType> = [
            HKQuantityType.quantityType(forIdentifier: .heartRate)!,
            HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned)!,
        ]
        
        healthStore.requestAuthorization(toShare: typesToShare, read: typesToRead) { (success, error) in
            if !success {
//                TODO: Levar para uma página dizendo que só pode usar o app após autorizar
            }
        }
    }
    
    func startWorkoutSession() {
        let configuration = HKWorkoutConfiguration()
        configuration.activityType = .functionalStrengthTraining
        configuration.locationType = .indoor
        
        do {
            session = try HKWorkoutSession(healthStore: healthStore, configuration: configuration)
            builder = session!.associatedWorkoutBuilder()
            
            builder!.dataSource = HKLiveWorkoutDataSource(healthStore: healthStore, workoutConfiguration: configuration)
            session!.delegate = self
            builder!.delegate = self
            session!.startActivity(with: Date())
            builder!.beginCollection(withStart: Date()) { success, error in
                if !success {
                    print("Unable to start collection, the error: \(String(describing: error))")
                    return
                }
                
                self.status = .inProgress
            }
        } catch {
            print("Unable to create workout session, the error: \(String(describing: error))")
        }
    }
    
    func endWorkoutSession() {
        guard let session = session else {
            print("Session is nil. Unable to end workout.")
            return
        }
        
        guard let builder = builder else {
            print("Builder is nil. Unable to end workout.")
            return
        }
        
        session.end()
        
        builder.endCollection(withEnd: Date()) { success, error in
            if !success {
                print("Unable to end collection")
                return
            }
            
            builder.finishWorkout { workout, error in
                if workout == nil {
                    print("Unable to read workout")
                    return
                }
                
                self.status = .complete
                self.workoutData = workout
            }
        }
    }
    
    func resumeWorkout() {
        guard let session = session else {
            print("Session is nil. Unable to end workout.")
            return
        }
        
        session.resume()
        self.status = .inProgress
    }
    
    func pauseWorkout() {
        guard let session = session else {
            print("Session is nil. Unable to end workout.")
            return
        }
        
        session.pause()
        self.status = .paused
    }
}

extension WorkoutSession {
    private func calculateBPM() -> Double? {
        let countUnit: HKUnit = .count()
        let minuteUnit: HKUnit = .minute()
        let beatsPerMinute: HKUnit = countUnit.unitDivided(by: minuteUnit)
        
        return self.heartRateStatistics?.mostRecentQuantity()?.doubleValue(for: beatsPerMinute)
    }
}

And when I call the workoutSession.startWorkoutSession() function, I get some errors:

2023-05-09 18:12:14.741560-0300 ApodWatch Watch App[67831:629899] [SwiftUI] Publishing changes from background threads is not allowed; make sure to publish values from the main thread (via operators like receive(on:)) on model updates.

I already tried to change to @State, @StateObject but doesn't work, my expectation was to work with @Published...

  • When you want to update a published var you need to do it in main thread (dispatch main async). Also : \@ObservedObject means that the object is not define in this view but somewhere else in parent hierarchy. If you want to define an object in a view that is ObservableObject you MUST degined it as \@StateObject. – Ptit Xav May 09 '23 at 21:36
  • I tried add: Button("Iniciar") { DispatchQueue.main.async { workoutSession.setUpSession() workoutSession.startWorkoutSession() } } in WorkoutView to start a workout session in dispatchquee but its giving me the error yet – Lucas Buchalla Sesti May 10 '23 at 01:53

1 Answers1

0

Try restructuring like this:

struct WorkoutView: View {
    @StateObject var workoutSession = WorkoutSession() // the init doesn't actually happen here
    
/* move this to WorkoutSession's init
    init() {
        workoutSession.setUpSession()
        workoutSession.startWorkoutSession()
    }
*/
     
    // @StateObject will init `WorkoutSession` just before body is called, which is just before its underlying UIView appears on screen.
    var body: some View {

Also you would be better having one View and @StateObject pair for the authorization. And then in that View's body check if authorised and init another View and other @StateObject for the workouts.

In Apple's Build a workout app for Apple Watch sample they use DispatchQueue.main.async in the workoutSession delegate method to prevent [SwiftUI] Publishing changes from background threads is not allowed. Unfortunately this example doesn't have error handling for when authorization fails so I still would suggest separating authorization and workouts into different objects and Views.

malhal
  • 26,330
  • 7
  • 115
  • 133
  • I tried to add this code Its still giving me the same error: 2023-05-10 14:56:47.229034-0300 ApodWatch Watch App[9520:232181] [SwiftUI] Publishing changes from background threads is not allowed; make sure to publish values from the main thread (via operators like receive(on:)) on model updates. – Lucas Buchalla Sesti May 10 '23 at 17:57
  • If the delegate methods are called from background threads you can’t set @Published vars – malhal May 10 '23 at 19:30
  • I followed this tutorial: https://www.youtube.com/watch?v=47c2WHymh5s, how his app works and my not? – Lucas Buchalla Sesti May 10 '23 at 20:01
  • In Apple's sample they use `DispatchQueue.main.async` in the workoutSession delegate method https://developer.apple.com/documentation/healthkit/workouts_and_activity_rings/build_a_workout_app_for_apple_watch – malhal May 11 '23 at 07:43