I had a setup using #State in my SwiftUI view and going all my operations in the View (loading API etc) however when attempting to restructure this away from using #ViewBuilder and #State and using a #ObservedObject ViewModel, I lost the ability to dynamically change my view based on the #State variables
My code is now
#ObservedObject private var contentViewModel: ContentViewModel
init(viewModel: ContentViewModel) {
self.contentViewModel = viewModel
}
var body: some View {
if contentViewModel.isLoading {
loadingView
}
else if contentViewModel.fetchError != nil {
errorView
}
else if contentViewModel.movies.isEmpty {
emptyListView
} else {
moviesList
}
}
However whenever these viewmodel properties change, the view doesn't update like it did when i used them in the class as #State properties...
ViewModel is as follows:
final class ContentViewModel: ObservableObject {
var movies: [Movie] = []
var isLoading: Bool = false
var fetchError: String?
private let dataLoader: DataLoaderProtocol
init(dataLoader: DataLoaderProtocol = DataLoader()) {
self.dataLoader = dataLoader
fetch()
}
func fetch() {
isLoading = true
dataLoader.loadMovies { [weak self] result, error in
guard let self = `self` else { return }
self.isLoading = false
guard let result = result else {
return print("api error fetching")
}
guard let error = result.errorMessage, error != "" else {
return self.movies = result.items
}
return self.fetchError = error
}
}
How can i bind these 3 state deciding properties to View outcomes now they are abstracted away to a viewmodel?
Thanks
Place #Published before all 3 of your properties like so:
#Published var movies: [Movie] = []
#Published var isLoading: Bool = false
#Published var fetchError: String?
You were almost there by making the class conform to ObservableObject but by itself that does nothing. You then need to make sure the updates are sent automatically by using the #Published as I showed above or manually send the objectWillChange.send()
Edit:
Also you should know that if you pass that data down to any children you should make the parents property be #StateObject and the children's be ObservedObject
Related
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.
In my project i hold a large dict of items that are updated via grpc stream. Inside the app there are several places i am rendering these items to UI and i would like to propagate the realtime updates.
Simplified code:
struct Item: Identifiable {
var id:String = UUID().uuidString
var name:String
var someKey:String
init(name:String){
self.name=name
}
}
class DataRepository {
public var serverSymbols: [String: CurrentValueSubject<Item, Never>] = [:]
// method that populates the dict
func getServerSymbols(serverID:Int){
someService.fetchServerSymbols(serverID: serverID){ response in
response.data.forEach { (name,sym) in
self.serverSymbols[name] = CurrentValueSubject(Item(sym))
}
}
}
// background stream that updates the values
func serverStream(symbols:[String] = []){
someService.initStream(){ update in
DispatchQueue.main.async {
self.serverSymbols[data.id]?.value.someKey = data.someKey
}
}
}
}
ViewModel:
class SampleViewModel: ObservableObject {
#Injected var repo:DataRepository // injection via Resolver
// hardcoded value here for simplicity (otherwise dynamically added/removed by user)
#Published private(set) var favorites:[String] = ["item1","item2"]
func getItem(item:String) -> Item {
guard let item = repo.serverSymbols[item] else { return Item(name:"N/A")}
return ItemPublisher(item: item).data
}
}
class ItemPublisher: ObservableObject {
#Published var data:Item = Item(name:"")
private var cancellables = Set<AnyCancellable>()
init(item:CurrentValueSubject<Item, Never>){
item
.receive(on: DispatchQueue.main)
.assignNoRetain(to: \.data, on: self)
.store(in: &cancellables)
}
}
Main View with subviews:
struct FavoritesView: View {
#ObservedObject var viewModel: QuotesViewModel = Resolver.resolve()
var body: some View {
VStack {
ForEach(viewModel.favorites, id: \.self) { item in
FavoriteCardView(item: viewModel.getItem(item: item))
}
}
}
}
struct FavoriteCardView: View {
var item:Item
var body: some View {
VStack {
Text(item.name)
Text(item.someKey) // dynamic value that should receive the updates
}
}
}
I must've clearly missed something or it's a completely wrong approach, however my Item cards do not receive any updates (i verified the backend stream is active and serverSymbols dict is getting updated). Any advice would be appreciated!
I've realised i've made a mistake - in order to receive the updates i need to pass down the ItemPublisher itself. (i was incorrectly returning ItemPublisher.data from my viewModel's method)
I've refactored the code and make the ItemPublisher provide the data directly from my repository using the item key, so now each card is subscribing individualy using the publisher.
Final working code now:
class SampleViewModel: ObservableObject {
// hardcoded value here for simplicity (otherwise dynamically added/removed by user)
#Published private(set) var favorites:[String] = ["item1","item2"]
}
MainView and CardView:
struct FavoritesView: View {
#ObservedObject var viewModel: QuotesViewModel = Resolver.resolve()
var body: some View {
VStack {
ForEach(viewModel.favorites, id: \.self) { item in
FavoriteCardView(item)
}
}
}
}
struct FavoriteCardView: View {
var itemName:String
#ObservedObject var item:ItemPublisher
init(_ itemName:String){
self.itemName = itemName
self.item = ItemPublisher(item:item)
}
var body: some View {
let itemData = item.data
VStack {
Text(itemData.name)
Text(itemData.someKey)
}
}
}
and lastly, modified ItemPublisher:
class ItemPublisher: ObservableObject {
#Injected var repo:DataRepository
#Published var data:Item = Item(name:"")
private var cancellables = Set<AnyCancellable>()
init(item:String){
self.data = Item(name:item)
if let item = repo.serverSymbols[item] {
self.data = item.value
item.receive(on: DispatchQueue.main)
.assignNoRetain(to: \.data, on: self)
.store(in: &cancellables)
}
}
}
I have 3 classes:
class ClassOne: ObservableObject {
#Published var loading: Bool = false
}
class ClassTwo: ObservableObject {
#Published var loading: Bool = false
}
class ClassThree: ObservableObject {
#Published var loading: Bool = false
}
In a SwiftUI view I need to do something when all loading variables are true
This is a simplified version of my files of course: loading var of every class is set true or false by a download method.
I just need something to check if all download are completed and remove the loading view.
struct MainScreen3: View {
#State private var cancellables = Set<AnyCancellable>()
#EnvironmentObject var classOne: ClassOne
#EnvironmentObject var classTwo: ClassTwo
#EnvironmentObject var classThree: ClassThree
#State private var loading: Bool = true
var body: some View {
VStack {
if loading {
Text("Please wait...")
} else {
Text("Done!")
}
}.onAppear {
self.classOne.fetchFromServer()
self.classTwo.fetchFromServer()
self.classThree.fetchFromServer()
}
}
}
Any suggestion?
You can use the computed property.
private var loading: Bool {
(self.classOne.loading || self.classTwo.loading || self.classThree.loading)
}
You can use combineLatest to combine all 3 loading values into a single Publisher. You can subscribe to this publisher using onReceive on the view and update the existing loading State property to trigger a UI update.
.onReceive(classOne.$loading.combineLatest(classTwo.$loading, classThree.$loading, { $0 && $1 && $2 })) { loading in
self.loading = loading
}
I have 2 independent ObservableObjects called ViewModel1 and ViewModel2.
ViewModel2 has an array of strings:
#Published var strings: [String] = [].
Whenever that array is modified i want ViewModel1 to be informed.
What's the recommended approach to achieve this?
Clearly, there are a number of potential solutions to this, like the aforementioned NotificationCenter and singleton ideas.
To me, this seems like a scenario where Combine would be rather useful:
import SwiftUI
import Combine
class ViewModel1 : ObservableObject {
var cancellable : AnyCancellable?
func connect(_ publisher: AnyPublisher<[String],Never>) {
cancellable = publisher.sink(receiveValue: { (newStrings) in
print(newStrings)
})
}
}
class ViewModel2 : ObservableObject {
#Published var strings: [String] = []
}
struct ContentView : View {
#ObservedObject private var vm1 = ViewModel1()
#ObservedObject private var vm2 = ViewModel2()
var body: some View {
VStack {
Button("add item") {
vm2.strings.append("\(UUID().uuidString)")
}
ChildView(connect: vm1.connect)
}.onAppear {
vm1.connect(vm2.$strings.eraseToAnyPublisher())
}
}
}
struct ChildView : View {
var connect : (AnyPublisher<[String],Never>) -> Void
#ObservedObject private var vm2 = ViewModel2()
var body: some View {
Button("Connect child publisher") {
connect(vm2.$strings.eraseToAnyPublisher())
vm2.strings = ["Other strings","From child view"]
}
}
}
To test this, first try pressing the "add item" button -- you'll see in the console that ViewModel1 receives the new values.
Then, try the Connect child publisher button -- now, the initial connection is cancelled and a new one is made to the child's iteration of ViewModel2.
In order for this scenario to work, you always have to have a reference to ViewModel1 and ViewModel2, or at the least, the connect method, as I demonstrated in ChildView. You could easily pass this via dependency injection or even through an EnvironmentObject
ViewModel1 could also be changed to instead of having 1 connection, having many by making cancellable a Set<AnyCancellable> and adding a connection each time if you needed a one->many scenario.
Using AnyPublisher decouples the idea of having a specific types for either side of the equation, so it would be just as easy to connect ViewModel4 to ViewModel1, etc.
I had same problem and I found this method working well, just using the idea of reference type and taking advantage of class like using shared one!
import SwiftUI
struct ContentView: View {
#StateObject var viewModel2: ViewModel2 = ViewModel2.shared
#State var index: Int = Int()
var body: some View {
Button("update strings array of ViewModel2") {
viewModel2.strings.append("Hello" + index.description)
index += 1
}
}
}
class ViewModel1: ObservableObject {
static let shared: ViewModel1 = ViewModel1()
#Published var onReceiveViewModel2: Bool = Bool() {
didSet {
print("strings array of ViewModel2 got an update!")
print("new update is:", ViewModel2.shared.strings)
}
}
}
class ViewModel2: ObservableObject {
static let shared: ViewModel2 = ViewModel2()
#Published var strings: [String] = [String]() {
didSet { ViewModel1.shared.onReceiveViewModel2.toggle() }
}
}
This is my main view where I create an object of getDepthData() that holds a string variable that I want to update when the user click the button below. But it never gets changed after clicking the button
import SwiftUI
struct InDepthView: View {
#State var showList = false
#State var pickerSelectedItem = 1
#ObservedObject var data = getDepthData()
var body: some View {
VStack(alignment: .leading) {
Button(action: {
self.data.whichCountry = "usa"
print(" indepthview "+self.data.whichCountry)
}) {
Text("change value")
}
}
}
}
Here is my class where I hold a string variable to keep track of the country they are viewing. But when every I try to modify the whichCountry variable it doesn't get changed
class getDepthData: ObservableObject {
#Published var data : Specific!
#Published var countries : HistoricalSpecific!
#State var whichCountry: String = "italy"
init() {
updateData()
}
func updateData() {
let url = "https://corona.lmao.ninja/v2/countries/"
let session = URLSession(configuration: .default)
session.dataTask(with: URL(string: url+"\(self.whichCountry)")!) { (data, _, err) in
if err != nil {
print((err?.localizedDescription)!)
return
}
let json = try! JSONDecoder().decode(Specific.self, from: data!)
DispatchQueue.main.async {
self.data = json
}
}.resume()
}
}
Any help would be greatly appreciated!
You need to define the whichCountry variable as #Published to apply changes on it
#Published var whichCountry: String = "italy"
You need to mark whichCountry as a #Published variable so SwiftUI publishes a event when this property have been changed. This causes the body property to reload
#Published var whichCountry: String = "italy"
By the way it is a convention to write the first letter of your class capitalized:
class GetDepthData: ObservableObject { }
As the others have mentioned, you need to define the whichCountry variable as #Published to apply changes to it. In addition you probably want to update your data because whichCountry has changed. So try this:
#Published var whichCountry: String = "italy" {
didSet {
self.updateData()
}
}