Using Combine's Publishers and Subscribers to publish real time HealthKit data? - swift

I've always used delegation in UIKit and WatchKit to communicate between objects as far as passing around data from e.g. a WorkoutManager ViewModel that receives delegate callbacks from HealthKit during an HKworkout for calories, heart rates, to an InterfaceController.
I'm now trying to use Combine and SwiftUI to pass around the same data and am a little lost. I'm using a WorkoutManager class as an environment object that I initialize in my ContentView:
class WorkoutManager: NSObject, HKWorkoutSessionDelegate, HKLiveWorkoutBuilderDelegate, ObservableObject {
#Published var totalEnergyBurned: Double = 0
//How to subscribe to the changes?
//Omitted HealthKit code that queries and pushes data into totalEnergyBurned here
}
struct ContentView: View {
let healthStore = HKHealthStore()
#StateObject var workoutManager = WorkoutManager()
var sessionTypes = [SessionType.Game, SessionType.Practice, SessionType.Pickup]
var body: some View {
List {
ForEach(sessionTypes) { sessionType in
NavigationLink(destination: LiveWorkoutView(sessionType: sessionType)) {
SessionTypeRow(name: sessionType.stringValue)
}
}
}
.navigationTitle("Let's Go!")
.onAppear {
let authorizationStatus = healthStore.authorizationStatus(for: HKSampleType.workoutType())
switch authorizationStatus {
case .sharingAuthorized:
print("sharing authorized")
case .notDetermined:
print("not determined")
HealthKitAuthManager.authorizeHealthKit()
case .sharingDenied:
print("sharing denied")
HealthKitAuthManager.authorizeHealthKit()
default:
print("default in healthStore.authorizationStatus in ContentView")
HealthKitAuthManager.authorizeHealthKit()
}
}
}
}
My goal is to Publish the changes to all of the children of ContentView but I'm not sure how to subscribe to the changes?
import SwiftUI
struct LiveWorkoutView: View {
#State var sessionType: SessionType
#StateObject var workoutManager = WorkoutManager()
var body: some View {
VStack {
Text("\(workoutManager.totalEnergyBurned)")
Button(action: {
workoutManager.stopWorkout()
}) {
Text("End Workout")
}
}
.onAppear {
workoutManager.startWorkout()
workoutManager.sessionType = sessionType
}
.navigationTitle(sessionType.stringValue)
}
}

//How to subscribe to the changes?
You don't. #StateObject injects subscriber in view, so just use workoutManager. totalEnergyBurned property somewhere (where needed) in view body and view will be refreshed automatically once this property changed (eg. you assign new value to it from HealthKit callback.

Related

Enum in viewmodel is not triggering refresh in SwiftUI

I've got a ViewModel which conforms the ObservableObject protocol.
This ViewModel holds a enum variable.
class DeviceViewModel:ObservableObject {
enum ConnectionState: Equatable, CaseIterable {
case NoConnected
case Connected
}
#Published var connectionState: ConnectionState = .NoConnected
}
I also got a simple view that it will change the text depending of that enum:
struct ContentView: View {
let viewModel: DeviceViewModel
var body: some View {
if viewModel.connectionState != .NoConnected {
Text("connected")
} else {
Text("No connected")
}
}
}
I've noticed that if the enum connectionState changes it won't trigger the view to refresh.
To test this I've added a init method in the ViewModel with the following asyncAfter:
init() {
DispatchQueue.main.asyncAfter(deadline: .now() + 5) { [weak self] in
guard let self = self else {
return
}
self.connectionState = .Connected
print("self.connectionState: \(self.connectionState)")
}
}
Any idea what I'm missing?
Thanks
The view needs to observe the changes in order to refresh:
struct ContentView: View {
#ObservedObject let viewModel: DeviceViewModel
...
Use #StateObject to declare your viewModel.
struct ContentView: View {
#StateObject var viewModel = DeviceViewModel()
var body: some View {
if viewModel.connectionState != .NoConnected {
Text("connected")
} else {
Text("No connected")
}
}
}

How to trigger automatic SwiftUI Updates with #ObservedObject using MVVM

I have a question regarding the combination of SwiftUI and MVVM.
Before we start, I have read some posts discussing whether the combination of SwiftUI and MVVM is necessary. But I don't want to discuss this here, as it has been covered elsewhere. I just want to know if it is possible and, if yes, how. :)
So here comes the code. I tried to add the ViewModel Layer in between the updated Object class that contains a number that should be updated when a button is pressed. The problem is that as soon as I put the ViewModel Layer in between, the UI does not automatically update when the button is pressed.
View:
struct ContentView: View {
#ObservedObject var viewModel = ViewModel()
#ObservedObject var numberStorage = NumberStorage()
var body: some View {
VStack {
// Text("\(viewModel.getNumberObject().number)")
// .padding()
// Button("IncreaseNumber") {
// viewModel.increaseNumber()
// }
Text("\(numberStorage.getNumberObject().number)")
.padding()
Button("IncreaseNumber") {
numberStorage.increaseNumber()
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
ViewModel:
class ViewModel: ObservableObject {
#Published var number: NumberStorage
init() {
self.number = NumberStorage()
}
func increaseNumber() {
self.number.increaseNumber()
}
func getNumberObject() -> NumberObject {
self.number.getNumberObject()
}
}
Model:
class NumberStorage:ObservableObject {
#Published var numberObject: NumberObject
init() {
numberObject = NumberObject()
}
public func getNumberObject() -> NumberObject {
return self.numberObject
}
public func increaseNumber() {
self.numberObject.number+=1
}
}
struct NumberObject: Identifiable {
let id = UUID()
var number = 0
} ```
Looking forward to your feedback!
I think your code is breaking MVVM, as you're exposing to the view a storage model. In MVVM, your ViewModel should hold only two things:
Values that your view should display. These values should be automatically updated using a binding system (in your case, Combine)
Events that the view may produce (in your case, a button tap)
Having that in mind, your ViewModel should wrap, adapt and encapsulate your model. We don't want model changes to affect the view. This is a clean approach that does that:
View:
struct ContentView: View {
#StateObject // When the view creates the object, it must be a state object, or else it'll be recreated every time the view is recreated
private var viewModel = ViewModel()
var body: some View {
VStack {
Text("\(viewModel.currentNumber)") // We don't want to use functions here, as that will create a new object , as SwiftUI needs the same reference in order to keep track of changes
.padding()
Button("IncreaseNumber") {
viewModel.increaseNumber()
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
ViewModel:
class ViewModel: ObservableObject {
#Published
private(set) var currentNumber: Int = 0 // Private set indicates this should only be mutated by the viewmodel
private let numberStorage = NumberStorage()
init() {
numberStorage.currentNumber
.map { $0.number }
.assign(to: &$currentNumber) // Here we're binding the current number on the storage to the published var that the view is listening to.`&$` basically assigns it to the publishers address
}
func increaseNumber() {
self.numberStorage.increaseNumber()
}
}
Model:
class NumberStorage {
private let currentNumberSubject = CurrentValueSubject<NumberObject, Never>(NumberObject())
var currentNumber: AnyPublisher<NumberObject, Never> {
currentNumberSubject.eraseToAnyPublisher()
}
func increaseNumber() {
let currentNumber = currentNumberSubject.value.number
currentNumberSubject.send(.init(number: currentNumber + 1))
}
}
struct NumberObject: Identifiable { // I'd not use this, just send and int directly
let id = UUID()
var number = 0
}
It's a known problem. Nested observable objects are not supported yet in SwiftUI. I don't think you need ViewModel+Model here since ViewModel seems to be enough.
To make this work you have to trigger objectWillChange of your viewModel manually when objectWillChange of your model is triggered:
class ViewModel: ObservableObject {
init() {
number.objectWillChange.sink { [weak self] (_) in
self?.objectWillChange.send()
}.store(in: &cancellables)
}
}
You better listen to only the object you care not the whole observable class if it is not needed.
Plus:
Since instead of injecting, you initialize your viewModel in your view, you better use StateObject instead of ObservedObject. See the reference from Apple docs: Managing model data in your app
One way you could handle this is to observe the publishers in your Storage class and send the objectWillChange publisher when it changes. I have done this in personal projects by adding a class that all my view models inherit from which provides a nice interface and handles the Combine stuff like this:
Parent ViewModel
import Combine
class ViewModel: ObservableObject {
private var cancellables: Set<AnyCancellable> = []
func publish<T>(on publisher: Published<T>.Publisher) {
publisher.sink { [weak self] _ in self?.objectWillChange.send() }
.store(in: &cancellables)
}
}
Specific ViewModel
class ContentViewModel: ViewModel {
private let numberStorage = NumberStorage()
var number: Int { numberStorage.numberObject.number }
override init() {
super.init()
publish(on: numberStorage.$numberObject)
}
func increaseNumber() {
numberStorage.increaseNumber()
}
}
View
struct ContentView: View {
#StateObject var viewModel = ContentViewModel()
var body: some View {
VStack {
Text("\(viewModel.number)")
.padding()
Button("IncreaseNumber") {
viewModel.increaseNumber()
}
}
}
}
Model/Storage
class NumberStorage:ObservableObject {
#Published var numberObject: NumberObject
init() {
numberObject = NumberObject()
}
public func increaseNumber() {
self.numberObject.number += 1
}
}
struct NumberObject: Identifiable {
let id = UUID()
var number = 0
}
This results in the view re-rendering any time Storage.numberObject changes.

SwiftUI - changes in nested View Model classes not detected using onChange method

I have a nested View Model class WatchDayProgramViewModel as an ObservableObject. Within WatchDayProgramViewModel, there is a WorkoutModel that is a child class. I want to detect any updates in the currentHeartRate to trigger data transfer to iPhone.
Hence, I tried from ContentView using WatchDayProgramViewModel as an EnvironmentObject and detecting changes in WorkoutModel via onChange() method. But it seems that SwiftUI views does not detect any property changes in WorkoutModel.
I understand that this issue could be due to ObservableObject not detecting changes in child/nested level of classes, and SO answer (SwiftUI change on multilevel children Published object change) suggests using struct instead of class. But changing WorkoutModel to struct result in various #Published properties and functions to show error.
Is there any possible way to detect changes in child View Model from the ContentView itself?
ContentView
struct ContentView: View {
#State var selectedTab = 0
#StateObject var watchDayProgramVM = WatchDayProgramViewModel()
var body: some View {
NavigationView {
TabView(selection: $selectedTab) {
WatchControlView().id(0)
NowPlayingView().id(1)
}
.environmentObject(watchDayProgramVM)
.onChange(of: self.watchDayProgramVM.workoutModel.currentHeartRate) { newValue in
print("WatchConnectivity heart rate from contentView \(newValue)")
}
}
}
WatchDayProgramViewModel
class WatchDayProgramViewModel: ObservableObject {
#Published var workoutModel = WorkoutModel()
init() {
}
}
WorkoutModel
import Foundation
import HealthKit
class WorkoutModel: NSObject, ObservableObject {
let healthStore = HKHealthStore()
var session: HKWorkoutSession?
var builder: HKLiveWorkoutBuilder?
#Published var currentHeartRate: Double = 0
#Published var workout: HKWorkout?
//Other functions to start/run workout hidden
func updateForStatistics(_ statistics: HKStatistics?) {
guard let statistics = statistics else {
return
}
DispatchQueue.main.async {
switch statistics.quantityType {
case HKQuantityType.quantityType(forIdentifier: .heartRate):
let heartRateUnit = HKUnit.count().unitDivided(by: HKUnit.minute())
self.currentHeartRate = statistics.mostRecentQuantity()?.doubleValue(for: heartRateUnit) ?? 0
default:
return
}
}//end of dispatchqueue
}// end of function
}
extension WorkoutModel: HKLiveWorkoutBuilderDelegate {
func workoutBuilder(_ workoutBuilder: HKLiveWorkoutBuilder, didCollectDataOf collectedTypes: Set<HKSampleType>) {
for type in collectedTypes {
guard let quantityType = type as? HKQuantityType else {
return
}
let statistics = workoutBuilder.statistics(for: quantityType)
updateForStatistics(statistics)
}
}
}
Try to change
#StateObject var watchDayProgramVM = WatchDayProgramViewModel()
with
#ObservedObject var watchDayProgramVM = WatchDayProgramViewModel()
Figure it out. Just had to create another AnyCancellable variable to call objectWillChange publisher.
WatchDayProgramViewModel
class WatchDayProgramViewModel: ObservableObject {
#Published var workoutModel = WorkoutModel()
var cancellable: AnyCancellable?
init() {
cancellable = workoutModel.objectWillChange
.sink { _ in
self.objectWillChange.send()
}
}
}
While I have provided my answer, that worksaround with viewmodels, I would love to see/get advice on other alternatives.

How can I get #AppStorage to work in an MVVM / SwiftUI framework?

I have a SettingsManager singleton for my entire app that holds a bunch of user settings. And I've got several ViewModels that reference and can edit the SettingsManager.
The app basically looks like this...
import PlaygroundSupport
import Combine
import SwiftUI
class SettingsManager: ObservableObject {
static let shared = SettingsManager()
#AppStorage("COUNT") var count = 10
}
class ViewModel: ObservableObject {
#Published var settings = SettingsManager.shared
func plus1() {
settings.count += 1
objectWillChange.send()
}
}
struct ContentView: View {
#StateObject var viewModel = ViewModel()
var body: some View {
VStack {
Button(action: viewModel.plus1) {
Text("\(viewModel.settings.count)")
}
}
}
}
let viewController = UIHostingController(rootView: ContentView())
PlaygroundPage.current.liveView = viewController
Frustratingly, it works about 85% of the time. But 15% of the time, the values don't update until navigating away from the view and then back.
How can I get #AppStorage to play nice with my View Model / MVVM framework?!
Came across this question researching this exact issue. I came down on the side of letting SwiftUI do the heavy lifting for me. For example:
// Use this in any view model you need to update the value
extension UserDefaults {
static func setAwesomeValue(with value: Int) {
UserDefaults.standard.set(value, forKey: "awesomeValue")
}
static func getAwesomeValue() -> Int {
return UserDefaults.standard.bool(forKey: "awesomeValue")
}
}
// In any view you need this value
struct CouldBeAnyView: some View {
#AppStorage("awesomeValue") var awesomeValue = 0
}
AppStorage is just a wrapper for UserDefaults. Whenever the view model updates the value of "awesomeValue", AppStorage will automatically pick it up. The important thing is to pass the same key when declaring #AppStorage. Probably shouldn't use a string literal but a constant would be easier to keep track of?
This SettingsManager in a cancellables set solution adapted from the Open Source ACHN App:
import PlaygroundSupport
import Combine
import SwiftUI
class SettingsManager: ObservableObject {
static let shared = SettingsManager()
#AppStorage("COUNT") var count = 10 {
willSet { objectWillChange.send() }
}
}
class ViewModel: ObservableObject {
#Published var settings = SettingsManager.shared
var cancellables = Set<AnyCancellable>()
init() {
settings.objectWillChange
.sink { [weak self] _ in
self?.objectWillChange.send()
}
.store(in: &cancellables)
}
func plus1() {
settings.count += 1
}
}
struct ContentView: View {
#StateObject var viewModel = ViewModel()
var body: some View {
VStack {
Button(action: viewModel.plus1) {
Text(" \(viewModel.settings.count) ")
}
}
}
}
let viewController = UIHostingController(rootView: ContentView())
PlaygroundPage.current.liveView = viewController
Seems to be slightly less glitchy, but still isn't 100% rock-solid consistent :(
Leaving this here to hopefully inspire someone with my attempt

SwiftUI persist observable objects

I am trying to understand data flows in swiftUI.
I have created a ViewModel which holds some data from a network request.
import SwiftUI
struct breakdown: Decodable {
var sms: Int
var im: Int
var total: Int
}
struct weeklyOverviewStruct: Decodable {
var data: [breakdown]
}
class WeeklyOverviewViewModel: ObservableObject {
#Published var overviewData: weeklyOverviewStruct?
func getBreakdown(){
semaphore.wait()
Network.Request(method: .GET , parameters: nil, endPoint: "rest/operator/stats/weekly/breakdown", completion: {
result,error in
if(error == nil){
do {
let breakdown = try JSONDecoder().decode(weeklyOverviewStruct.self, from: result!)
DispatchQueue.main.async {
self.overviewData = breakdown
}
}catch{
print("Json Error")
}
}else{
print("\(error!)")
}
})
}
}
It my understanding that I can then observe this ViewModel in a second view and the view will re-draw if the ViewModel changes:
struct SecondView: View {
#ObservedObject var WeeklyOverviewVM = WeeklyOverviewViewModel()
var body: some View {
Text(String.init(describing: WeeklyOverviewVM.overviewData?.data[0].total))
}
}
If however the second view is presented after the getBreakDown() is called the observedObject is nil.
Is there a way of persisting the data so that even if a view is presented after the getBreakdown() function is called, the data from the previous request is observable in the second view?
The SecondView now creates view model every time it is instantiated, so if you want previously fetched data persist, it needs to store that view model instance somewhere outside of SecondView and inject it on its creation, like
struct SecondView: View {
// only declare
#ObservedObject var WeeklyOverviewVM: WeeklyOverviewViewModel
and somewhere in parent view
...
// inject WeeklyOverviewViewModel from own property
SecondView(WeeklyOverviewVM: self.secondViewVM)
...