Share binding between two ViewModels in Swift - swift

I just got started with SwiftUI and I would like to use ViewModels to encapsulate my logic, and separate it from my Views.
Now I just hit my first roadblock and I am not sure how to get passed this.
So my app so far is fairly simple. I have two Views, each with their own ViewModels: Parent and Child.
The Parent ViewModel holds a list of Items, which are fetched from a backend API. I want to pass this to Child and its ViewModel, since it is responsible for adding Items to the list.
Here's the simplified code for this:
struct ParentView: View {
#StateObject private var viewModel = ViewModel()
var body: some View {
VStack {
ChildView()
Text("Items: \(viewModel.items.count)")
}
}
}
extension ParentView {
#MainActor class ViewModel: ObservableObject {
#Published var items: [Item] = []
}
}
struct ChildView: View {
#StateObject private var viewModel = ViewModel()
var body: some View {
List {
ForEach(viewModel.items) { item in
Text(item.name)
}
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
viewModel.AddItem()
} label: {
Label("Add item", systemImage: "plus")
}
}
}
}
}
extension ChildView {
#MainActor class ViewModel: ObservableObject {
#Published var items: [Item] = []
func AddItem() {
items.append(Item(name: "Test"))
}
}
}
How can I make it so that the list of items from the parent view model is passed down to the view model of the child, ensuring that there is only a single list, while also making sure that both views get refreshed when this list changes?
Thanks!

You must only have one source of truth, in your ParentView that you then pass to the child views. Currently you have multiple ViewModel that have no relations to each other.
In ChildView replace #StateObject private var viewModel = ViewModel() with
#ObservedObject var viewModel: ViewModel, and in ParentView,
use ChildView(viewModel: viewModel) to pass the ViewModel to it.
Remove also extension ChildView ... and take #MainActor class ViewModel: ObservableObject out of the extension ParentView.
Have a look at this link, it gives you some good examples of how to use ObservableObject and manage data in your app https://developer.apple.com/documentation/swiftui/managing-model-data-in-your-app
EDIT-1:
Here is my full test example code to show how the parent view model
is passed down to the child view ...ensuring that there is only a single list, while also making sure that both views get refreshed when this list changes
struct ContentView: View {
var body: some View {
NavigationStack { // <-- here
ParentView()
}
}
}
struct Item: Identifiable {
let id = UUID()
var name:String
}
struct ParentView: View {
#StateObject var viewModel = ViewModel() // <-- here
var body: some View {
VStack {
ChildView(viewModel: viewModel) // <-- here
Text("Items: \(viewModel.items.count)")
}
}
}
// -- here
#MainActor class ViewModel: ObservableObject {
#Published var items: [Item] = [Item(name: "item-1"), Item(name: "item-2")]
}
struct ChildView: View {
#ObservedObject var viewModel: ViewModel // <-- here
var body: some View {
List {
ForEach(viewModel.items) { item in
Text(item.name)
}
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
viewModel.items.append(Item(name: "Test")) // <-- here
} label: {
Label("Add item", systemImage: "plus")
}
}
}
}
}

Related

SwiftUI: #StateObject init multiple times

I'm trying to optimize my SwiftUI app. I have a strange behavior with a ViewModel stored as a #StateObject in its View. To understand the issue, I made a small project that reproduces it.
ContentView contains a button to open ChildView in a sheet. ChildView is stored as property as I don't want to recreate it every time the sheet is open by user (this works):
struct ContentView: View {
#State private var displayingChildView = false
private let childView = ChildView()
var body: some View {
Button(action: {
displayingChildView.toggle()
}, label: {
Text("Display child view")
})
.sheet(isPresented: $displayingChildView, content: {
childView // instead of: ChildView()
})
}
}
ChildView code:
struct ChildView: View {
#StateObject private var viewModel = ViewModel()
init() {
print("init() of ChildView")
}
var body: some View {
VStack {
Button(action: {
viewModel.add()
}, label: {
Text("Add 1 to count")
})
Text("Count: \(viewModel.count)")
}
}
}
And its ViewModel:
class ViewModel: ObservableObject {
#Published private(set) var count = 0
init() {
print("init() of ViewModel")
}
func add() {
count += 1
}
}
Here is the issue:
The ViewModel's init is called every time user opens the sheet. Why?
As ViewModel is a #StateObject in ChildView and ChildView is only init once, I am expecting that ViewModel is also only init once.
I have read this article that says that :
Observed objects marked with the #StateObject property wrapper don’t get destroyed and re-instantiated at times their containing view struct redraws.
Or here:
Use #StateObject once for each observable object you use, in whichever part of your code is responsible for creating it.
So I understand that ViewModel should stay alive, especially as ChildView is not destroyed.
And what confuses me the most is that if I replace #StateObject with #ObservedObject it works as expected. But it is not recommended to store an #ObservedObject inside a View.
Can anyone explain why this behavior and how to fix it as expected (ViewModel init should be called once) ?
A possible solution:
I've found a possible solution to fix this behavior:
a. Move the declaration of ViewModel into ContentView:
#StateObject private var viewModel = ViewModel()
b. Change the declaration of ViewModel in ChildView to be an EnvironmentObject:
#EnvironmentObject private var viewModel: ViewModel
c. And inject it in childView:
childView
.environmentObject(viewModel)
That means it's ContentView that is responsible to keep the ChildView's ViewModel alive. It works, but I find this solution quite ugly:
All future child Views of ChildView could get access to ViewModel through environment objects. But it's no sense as it should be only useful for its View.
I would prefer declare a ViewModel inside its View instead of inside its parent View.
And this solution still doesn't explain above questions about #StateObject that should stay alive...
SwiftUI initializes the #State variables when a view is inserted into the view hierarchy. This is why your attempt to keep the state of the child view alive by assigning it to a var fails. Every time your sheet is presented, the child view is added to the view hierarchy and its state variables are initialized.
The correct way to do this is to pass the viewModel to the child view.
struct ContentView: View {
#StateObject private var viewModel = ViewModel()
#State private var displayingChildView = false
var body: some View {
Button(action: {
displayingChildView.toggle()
}, label: {
Text("Display child view")
})
.sheet(isPresented: $displayingChildView, content: {
ChildView(viewModel: viewModel)
})
}
}
struct ChildView: View {
#ObservedObject var viewModel: ViewModel
var body: some View {
VStack {
Button(action: {
viewModel.add()
}, label: {
Text("Add 1 to count")
})
Text("Count: \(viewModel.count)")
}
}
}
Objects slow down SwiftUI, to use it effectively we need to forget about view model objects and learn value types, structs, mutating func, closure captures, etc. Here is how it should be done:
struct Counter {
private(set) var count = 0
init() {
print("init() of Counter")
}
mutating func add() {
count += 1
}
}
struct ChildView: View {
#State private var counter = Counter()
init() {
print("init() of ChildView")
}
var body: some View {
VStack {
Button(action: {
counter.add()
}, label: {
Text("Add 1 to count")
})
Text("Count: \(counter.count)")
}
}
}

Propertly break down and pass data between views

So I'm still learning Swift and I wanted to cleanup some code and break down views, but I can't seem to figure out how to pass data between views, so I wanted to reach out and check with others.
So let's say that I have MainView() which previously had this:
struct MainView: View {
#ObservedObject var model: MainViewModel
if let item = model.selectedItem {
HStack(alignment: .center, spacing: 3) {
Text(item.title)
}
}
}
Now I created a SecondView() and changed the MainView() content to this:
struct MainView: View {
#ObservedObject var model: MainViewModel
if let item = model.selectedItem {
SecondView(item: item)
}
}
Inside SecondView(), how can I access the item data so that I can use item.title inside SecondView() now?
In order to pass item to SecondView, declare item as a let property and then when you call it with SecondView(item: item), SecondView can refer to item.title.
Here is a complete example expanding on your code:
import SwiftUI
struct Item {
let title = "Test Title"
}
class MainViewModel: ObservableObject {
#Published var selectedItem: Item? = Item()
}
struct MainView: View {
#ObservedObject var model: MainViewModel
var body: some View {
if let item = model.selectedItem {
SecondView(item: item)
}
}
}
struct SecondView: View {
let item: Item
var body: some View {
Text(item.title)
}
}
struct ContentView: View {
#StateObject private var model = MainViewModel()
var body: some View {
MainView(model: model)
}
}

SwiftUI - Nested links within NavigationStack inside a NavigationSplitView not working

I'm playing around with the new navigation API's offered in ipadOS16/macOS13, but having some trouble working out how to combine NavigationSplitView, NavigationStack and NavigationLink together on macOS 13 (Testing on a Macbook Pro M1). The same code does work properly on ipadOS.
I'm using a two-column NavigationSplitView. Within the 'detail' section I have a list of SampleModel1 instances wrapped in a NavigationStack. On the List I've applied navigationDestination's for both SampleModel1 and SampleModel2 instances.
When I select a SampleModel1 instance from the list, I navigate to a detailed view that itself contains a list of SampleModel2 instances. My intention is to navigate further into the NavigationStack when clicking on one of the SampleModel2 instances but unfortunately this doesn't seem to work. The SampleModel2 instances are selectable but no navigation is happening.
When I remove the NavigationSplitView completely, and only use the NavigationStack the problem does not arise, and i can successfully navigate to the SampleModel2 instances.
Here's my sample code:
// Sample model definitions used to trigger navigation with navigationDestination API.
struct SampleModel1: Hashable, Identifiable {
let id = UUID()
static let samples = [SampleModel1(), SampleModel1(), SampleModel1()]
}
struct SampleModel2: Hashable, Identifiable {
let id = UUID()
static let samples = [SampleModel2(), SampleModel2(), SampleModel2()]
}
// The initial view loaded by the app. This will initialize the NavigationSplitView
struct ContentView: View {
enum NavItem {
case first
}
var body: some View {
NavigationSplitView {
NavigationLink(value: NavItem.first) {
Label("First", systemImage: "house")
}
} detail: {
SampleListView()
}
}
}
// A list of SampleModel1 instances wrapped in a NavigationStack with multiple navigationDestinations
struct SampleListView: View {
#State var path = NavigationPath()
#State var selection: SampleModel1.ID? = nil
var body: some View {
NavigationStack(path: $path) {
List(SampleModel1.samples, selection: $selection) { model in
NavigationLink("\(model.id)", value: model)
}
.navigationDestination(for: SampleModel1.self) { model in
SampleDetailView(model: model)
}
.navigationDestination(for: SampleModel2.self) { model in
Text("Model 2 ID \(model.id)")
}
}
}
}
// A detailed view of a single SampleModel1 instance. This includes a list
// of SampleModel2 instances that we would like to be able to navigate to
struct SampleDetailView: View {
var model: SampleModel1
var body: some View {
Text("Model 1 ID \(model.id)")
List (SampleModel2.samples) { model2 in
NavigationLink("\(model2.id)", value: model2)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
I removed this unclear ZStack and all works fine. Xcode 14b3 / iOS 16
// ZStack { // << this !!
SampleListView()
// }
Apple just releases macos13 beta 5 and they claimed this was resolved through feedback assistant, but unfortunately this doesn't seem to be the case.
I cross-posted this question on the apple developers forum and user nkalvi posted a workaround for this issue. I’ll post his example code here for future reference.
import SwiftUI
// Sample model definitions used to trigger navigation with navigationDestination API.
struct SampleModel1: Hashable, Identifiable {
let id = UUID()
static let samples = [SampleModel1(), SampleModel1(), SampleModel1()]
}
struct SampleModel2: Hashable, Identifiable {
let id = UUID()
static let samples = [SampleModel2(), SampleModel2(), SampleModel2()]
}
// The initial view loaded by the app. This will initialize the NavigationSplitView
struct ContentView: View {
#State var path = NavigationPath()
enum NavItem: Hashable, Equatable {
case first
}
var body: some View {
NavigationSplitView {
List {
NavigationLink(value: NavItem.first) {
Label("First", systemImage: "house")
}
}
} detail: {
SampleListView(path: $path)
}
}
}
// A list of SampleModel1 instances wrapped in a NavigationStack with multiple navigationDestinations
struct SampleListView: View {
// Get the selection from DetailView and append to path
// via .onChange
#State var selection2: SampleModel2? = nil
#Binding var path: NavigationPath
var body: some View {
NavigationStack(path: $path) {
VStack {
Text("Path: \(path.count)")
.padding()
List(SampleModel1.samples) { model in
NavigationLink("Model1: \(model.id)", value: model)
}
.navigationDestination(for: SampleModel2.self) { model in
Text("Model 2 ID \(model.id)")
.navigationTitle("navigationDestination(for: SampleModel2.self)")
}
.navigationDestination(for: SampleModel1.self) { model in
SampleDetailView(model: model, path: $path, selection2: $selection2)
.navigationTitle("navigationDestination(for: SampleModel1.self)")
}
.navigationTitle("First")
}
.onChange(of: selection2) { newValue in
path.append(newValue!)
}
}
}
}
// A detailed view of a single SampleModel1 instance. This includes a list
// of SampleModel2 instances that we would like to be able to navigate to
struct SampleDetailView: View {
var model: SampleModel1
#Binding var path: NavigationPath
#Binding var selection2: SampleModel2?
var body: some View {
NavigationStack {
Text("Path: \(path.count)")
.padding()
List(SampleModel2.samples, selection: $selection2) { model2 in
NavigationLink("Model2: \(model2.id)", value: model2)
// This also works (without .onChange):
// Button(model2.id.uuidString) {
// path.append(model2)
// }
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}

SwiftUI pass overlay content to parent views

MainView and AnotherMainView contain names arrays. They iterate over these arrays and both use the DetailView to show the name with some additional content. MyViewModel provides some functionalities such as adding a name etc. These should be triggered from a DetailOverlayView which uses the selected name for the actions (please see code comments.
My problem is, starting an overlay within the DetailView results in a very small overlay within the DetailView "cell" which is not what I want. This is why I would like to show it on the parent view. Unfortunately, I don't know how to pass the DetailOverlayView to the MainView and AnotherMainView. I tried with bindings or environment but this did not work. I would like to avoid redundancy such as defining overlays in both parent views etc.
Alternatively, maybe there is a solution to call it from the DetailView, but bypassing the detail view frame without giving some hardcoded height and width values.
struct MainView: View {
#StateObject var viewModel: MyViewModel
var names = ["aaa", "ccc", "ddd", "gght"]
var body: some View {
VStack {
ForEach(names, id: \.self, content: { name in
DetailView(name: name).environmentObject(viewModel)
})
}///.overlay(... -> need DetailOverlayView here
}
}
struct AnotherMainView: View {
#StateObject var viewModel: MyViewModel
var names = ["bbb", "hhh"]
var body: some View {
VStack {
ForEach(names, id: \.self, content: { name in
DetailView(name: name).environmentObject(viewModel)
})
}///.overlay(... -> need DetailOverlayView here
}
}
struct DetailView: View {
#EnvironmentObject var viewModel: MyViewModel
#State var name: String
var body: some View {
VStack {
Text("Name of the user: ")
Text(name)
}///.overlay(... -> this would show an DetailOverlayView as a part of this small DetailView and in its bounds which is not what I want
}
struct DetailOverlayView: View {
#Binding var name: String ///this is the same name that the DetailView has, not sure if binding makes sense here
#EnvironmentObject var viewModel: MyViewModel
var body: some View {
VStack {
Button(action: {
viewModel.addName(name: name)
}, label: {
Text("Add name")
})
Button(action: {
viewModel.doSomething1(name: name)
}, label: {
Text("doSomething1")
})
Button(action: {
viewModel.doSomething2(name: name)
}, label: {
Text("doSomething2")
})
}
}
}
}
class MyViewModel: ObservableObject {
func addName(name: String) {
///
}
func doSomething1(name: String) {
///
}
func doSomething2(name: String) {
///
}
}

Changing #Published value triggers Init in View

I am facing a problem initializing the view, it is reinitialized when the #Published property is set and I cannot figure out why.
The app structure is:
MyApp -> MainView -> fullscreenCover OrderView -> ListOfOrders -> fullscreenCover ProductView -> Bug Button. The view model is injected as #StateObject.
Here is a simplified version of the app I'm working on:
View model
class SystemService: ObservableObject {
#Published private(set) var testValue: Bool = false
#Published private(set) var products: [Product] = []
}
The App declaration
#main
struct MyApp: App {
#StateObject private var systemService = SystemService()
#ViewBuilder
var body: some Scene {
WindowGroup {
MainView(systemService: systemService)
}
}
}
The main view, which basically just shows a fullscreen modal view - OrderView
struct MainView: View {
#StateObject var systemService: SystemService
#State private var activeFullScreen: ActiveFullScreenEnum?
var body: some View {
VStack {
Button(action: {
activeFullScreen = .order
}, label: Text("Order Details"))
}
.fullScreenCover(item: $activeFullScreen, content: { item in
switch item {
case .order:
OrderView(systemService: systemService)
}
})
}
}
The Order View contains a list of products.
struct OrderView: View {
#StateObject var systemService: SystemService
#State private var activeFullScreen: OrderFullScreenEnum?
init(systemService: SystemService) {
_systemService = StateObject(wrappedValue: systemService)
print("OrderScreen Initialized")
}
var body: some View {
VStack {
ScrollView {
ForEach(systemService.products) { product in
Button(action: {
activeFullScreen = .product(product)
}, label: Text("Prodcut Details"))
}
}
}
.fullScreenCover(item: $activeFullScreen, content: { item in
switch item {
case .product(let product):
ProductView(systemService: systemService, product: product)
}
})
}
}
And finally the Product View where the bug is discovered.
The bug is - when pressing on the "Bug Button" the ProductView is dismisses, and OrderView's init calls and prints out "OrderScreen Initialized".
struct ProductView: View {
#StateObject var systemService: SystemService
var body: some View {
VStack {
Button(action: {
systemService.testValue.toggle()
}, label: Text("Bug Button"))
}
}
}
Probably the issue in my fundamental misunderstanding of how Combine works, I will be grateful if somebody could help.
***** Additional info *****
If I add .onAppear to the Order View
.onAppear {
print("Order View Did Appear")
}
The first call to systemService.testValue.toggle() from the Ordre View triggers .onAppear, but only once, only the first time. After that, the bug disappears and .fullScreenCover doesn't get dismissed anymore.
I think the main problem is that the application may be trying to operate with four different instances of SystemServices.
#StateObject - 'A property wrapper type that instantiates an observable object.'
#ObservedObject - 'A property wrapper type that subscribes to an observable object and invalidates a view whenever the observable object changes.'
Instead of #StateObject, try giving #ObservedObject in the child views of App a go.
Good luck.