-2

Apologies if this seems a bit basic, but I really don't understand what the best practice is for solving this problem. I have tried saving the selected house in UserDefaults then checking to see if the selected house has changed using the .viewDidAppear{} callback. This works, but is definitely hacky and has side effects. Basically what I want to happen is when the user selects a house using HouseViewModel, the RoomsViewModel should trigger loadRoomsForHouse(selectedHouse) and show the correct String.

import SwiftUI

struct HomeView: View {
    
    var body: some View {
        
        TabView {
            RoomsView().tabItem {
                Label("Rooms", systemImage: "pencil")
            }
            
            HousePickerView().tabItem {
                Label("House Picker", systemImage: "magnifyingglass")
            }
            
        }
        
    }
}

struct RoomsView: View {
    @StateObject var roomsViewModel = RoomsViewModel()
    
    var body: some View {
        NavigationStack {
            VStack {
                Text("Rooms:")
                Text(roomsViewModel.roomsBasedOnHouse)
            }
            
        }
    }
}

struct HousePickerView: View {
    @StateObject var homeViewModel = HousePickerViewModel()
    
    var body: some View {
        NavigationStack {
            VStack {
                List(homeViewModel.houses) { house in
                    HStack {
                        
                        if(house.id == homeViewModel.selectedHouse?.id) {
                            Image(systemName: "checkmark")
                        }
                        Text(house.name)
                    }.onTapGesture {
                        homeViewModel.pickHouse(house: house)
                    }
                }
            }
        }
    }
}

class RoomsViewModel: ObservableObject {
    
    @Published var roomsBasedOnHouse: String = ""
    
    // ******************************************************************************
    // here is where I want to listen for when a different house is selected and call
    // loadRoomsForHouse(selectedHouse) to update the string
    // ******************************************************************************
    
    
    func loadRoomsForHouse(house: House) {
        
        if(house.name == "Home") {
            roomsBasedOnHouse = "Living Room, Bedroom, Garage"
        } else if (house.name == "Vacation Home"){
            roomsBasedOnHouse = "Patio, Pool Room, Bar"
        }
        
    }
    
}

class HousePickerViewModel: ObservableObject {
    @Published var houses = [House](arrayLiteral: House(id: "abc", name: "Home"), House(id: "def", name: "Vacation Home"))
    
    @Published var selectedHouse: House? = nil
    
    func pickHouse(house: House) {
        selectedHouse = house
    }
    
}

public struct House: Identifiable {
    public var id : String
    public var name: String
}

struct HomeView_Previews: PreviewProvider {
    static var previews: some View {
        HomeView()
    }
}

Cameron Henige
  • 368
  • 2
  • 17
  • ViewModels should never depend on each other or even know about each other. You can move any shared data to a “manager” of sorts that they can both access. – lorem ipsum Apr 14 '23 at 19:52
  • You'll need to learn the View struct, we don't use view model objects in SwiftUI. The View struct holds the view data and body is called when it changes. – malhal Apr 16 '23 at 14:44

2 Answers2

0

While your own answer no doubt works, it is not an ideal solution. Using an EnvironmentObject is overkill for your needs, especially when you still have an ObservableObject. You are also publishing static properties that are not being used to drive view refresh, which is uncecesaary. Plus you are building logic into the views that would be better in the data model.

I've tried in the answer below to suggest alternative solutions while trying to stay mainly true to your original solution (there are other optimisations, but they would mean the answer has little relevance to your sample code). I've also got rid of the viewModels as for such a simple task they are not needed.

Firstly you can build more of the logic into the House object and into what I've called Model which centralises some of the data that was (but shouldn't be) held in the viewModels.

struct House: Identifiable {
   public var id : String
   public var name: String
   
   var rooms: String {
      switch name {
         case "Home": return  ["Living Room, Bedroom, Garage"].joined()
         case "Vacation Home": return ["Patio, Pool Room, Bar"].joined()
         default: return "Unknown type of house"
      }
   }
}

class Model: ObservableObject {
   let houses = [House](arrayLiteral: House(id: "abc", name: "Home"), House(id: "def", name: "Vacation Home"))
   @Published var selectedHouse: House? = nil
   
   func isSelected(_ house: House) -> Bool {
      house.id == selectedHouse?.id
   }
   
   func set(house: House) {
      selectedHouse = house
   }
}

Note that the only data that needs to be published is the selectedHouse as this is the item that can change and that will drive the view to refresh. As the houses array is static I've made it a constant. (NB. using string to identify house types isn't a great solution, an enum would work better).

All this lets the views be far simpler. Note that the data is instantiated as a @StateObject in the root view and then injected into @ObservableObjects in the child views.

struct HomeView: View {
   @StateObject var model: Model = Model()
   var body: some View {
      TabView {
         RoomsView(model: model).tabItem {
            Label("Rooms", systemImage: "pencil")
         }
         HousePickerView(model: model).tabItem {
            Label("House Picker", systemImage: "magnifyingglass")
         }
      }
   }
}


struct RoomsView: View {
   @ObservedObject var model: Model
   
   var body: some View {
      NavigationStack {
         VStack {
            Text("Rooms:")
            Text( model.selectedHouse != nil  ? model.selectedHouse!.rooms : "None"
            )
         }
      }
   }
}


struct HousePickerView: View {
   @ObservedObject var model: Model
   
   var body: some View {
      NavigationStack {
         List(model.houses) { house in
            HStack {
               Image(systemName: model.isSelected(house) ? "checkmark.square": "square")
               Text(house.name)
            }.onTapGesture {
               model.set(house: house)
            }
         }
      }
   }
}
flanker
  • 3,840
  • 1
  • 12
  • 20
  • selection should be `@State` so different model data can be selected on different screens. – malhal Apr 16 '23 at 14:45
  • @malhal the OP wanted a shared state across all screen, such that changes in one tab were represented on the other. Unless I'm misunderstanding something that's not possible with `@State`? – flanker Apr 16 '23 at 17:37
  • Easy when you know how `struct RoomsView: View { let selectedHouse: House?` – malhal Apr 16 '23 at 23:00
  • OK... Am I right then in: the `TabView` is consuming a published prop so is refreshed whenever that prop changes, which will effect the change required in `RoomsView` content, and new data will be displayed based on the injected value. But `RoomView` will be redrawn in its entireity as there is nothing directly linked to a published value that can be used for a refresh of just the `Text` view (the only bit that is actually changing)? – flanker Apr 17 '23 at 00:34
  • Thanks for the answers! I really appreciate them. However, I think it avoids the issue I am trying to solve by removing the RoomsViewModel. Let's say I needed to send the number of rooms inside of the house to the backend for some reason on some unrelated screen. How would I get that number into a ViewModel that is mostly unrelated to the Model object? I could send it to the ViewModel through a button click or something, but what if I needed it inside of the init function? – Cameron Henige Apr 17 '23 at 02:56
  • In SwiftUI the View struct is a view model already you don't need another one, to send to back end you would use `.task(id: rooms.count)` – malhal Apr 17 '23 at 04:07
-1

I figured it out. The solution was to use a combination of EnvironmentObject and "onReceive". Here is the working code.

import SwiftUI

struct HomeView: View {
    @StateObject var housePickerViewModel = HousePickerViewModel()
    
    
    var body: some View {
        
        TabView {
            RoomsView().tabItem {
                Label("Rooms", systemImage: "pencil")
            }
            
            HousePickerView().tabItem {
                Label("House Picker", systemImage: "magnifyingglass")
            }
            
        }.environmentObject(housePickerViewModel)
        
    }
}

struct RoomsView: View {
    @StateObject var roomsViewModel = RoomsViewModel()
    @EnvironmentObject var homeViewModel: HousePickerViewModel
    
    var body: some View {
        NavigationStack {
            VStack {
                Text("Rooms:")
                Text(roomsViewModel.roomsBasedOnHouse)
            }
            
        }.onReceive(homeViewModel.$selectedHouse) { house in
            roomsViewModel.loadRoomsForHouse(house: house)
        }
        
    }
}

struct HousePickerView: View {
    @EnvironmentObject var homeViewModel: HousePickerViewModel
    
    var body: some View {
        NavigationStack {
            VStack {
                List(homeViewModel.houses) { house in
                    HStack {
                        
                        if(house.id == homeViewModel.selectedHouse?.id) {
                            Image(systemName: "checkmark")
                        }
                        Text(house.name)
                    }.onTapGesture {
                        homeViewModel.pickHouse(house: house)
                    }
                }
            }
        }
    }
}

class RoomsViewModel: ObservableObject {
    
    @Published var roomsBasedOnHouse: String = ""
    
    func loadRoomsForHouse(house: House?) {
        
        if(house?.name == "Home") {
            roomsBasedOnHouse = "Living Room, Bedroom, Garage"
        } else if (house?.name == "Vacation Home"){
            roomsBasedOnHouse = "Patio, Pool Room, Bar"
        }
        
    }
    
}

class HousePickerViewModel: ObservableObject {
    @Published var houses = [House](arrayLiteral: House(id: "abc", name: "Home"), House(id: "def", name: "Vacation Home"))
    
    @Published var selectedHouse: House? = nil
    
    func pickHouse(house: House) {
        selectedHouse = house
    }
    
}

public struct House: Identifiable {
    public var id : String
    public var name: String
}

struct HomeView_Previews: PreviewProvider {
    static var previews: some View {
        HomeView()
    }
}

Cameron Henige
  • 368
  • 2
  • 17
  • `onReceive` is not designed for this. When an `@Published` var is changed, `body` is already called automatically. You need to load in `pickHouse`. It would help if you removed the unnecessary view model objects and used the `View` struct and `@State`. – malhal Apr 16 '23 at 14:47