We are using SwiftUI with a NavigationView.
We have one view (ParentView) that updates its data asynchronously through its view model (ParentViewModel) and then re-renders its body according to the new data. When the completion block is called after we have entered the second view (ChildView) through the NavigationLink, problems occur, e.g. the app automatically navigating back to the first view without any user interaction, or missing completion block calls in the second view.
struct ParentView: View {
#ObservedObject private var viewModel = ProjectListViewModel()
var body: some View {
ForEach(viewModel.items) { item in
NavigationLink(destination: ChildView(item: item)) {
Text(item.label)
}
}.onAppear(perform: viewModel.loadData))
}
}
class ParentViewModel: ObservableObject {
#Published var items: [Item] = []
func loadData() {
loadDataAsynchronously { [weak self] newItems in
// Problems occur if this completion block is called after we have entered ChildView
self?.items = newItems
}
}
}
class Item: Identifiable { ... }
This error occurs on the simulator as well as on a physical device.
Related
Cow you give me some confirmation about my understanding about #ObservedObject and #EnvironmentObject?
In my mind, using an #ObservedObject is useful when we send data "in line" between views that are sequenced, just like in "prepare for" in UIKit while using #EnvironmentObject is more like "singleton" in UIKit. My question is, is my code making the right use of these two teniques? Is this the way are applied in real development?
my model used as brain for funcions (IE urls sessions, other data manipulations)
class ModelClass_ViaObservedObject: ObservableObject {
#Published var isOn: Bool = true
}
class ModelClass_ViaEnvironment: ObservableObject {
#Published var message: String = "default"
}
my main view
struct ContentView: View {
//way to send data in views step by step
#StateObject var modelClass_ViaObservedObject = ModelClass_ViaObservedObject()
//way to share data more or less like a singleton
#StateObject var modelClass_ViaEnvironment = ModelClass_ViaEnvironment()
var myBackgroundColorView: Color {
if modelClass_ViaObservedObject.isOn {
return Color.green
} else {
return Color.red
}
}
var body: some View {
NavigationView {
ZStack {
myBackgroundColorView
VStack {
NavigationLink(destination:
SecondView(modelClass_viaObservedObject: modelClass_ViaObservedObject)
) {
Text("Go to secondary view")
.padding()
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(.black, lineWidth: 1)
)
}
Text("text received from second view: \(modelClass_ViaEnvironment.message)")
}
}
.navigationTitle("Titolo")
.navigationBarTitleDisplayMode(.inline)
}
.environmentObject(modelClass_ViaEnvironment)
}
}
my second view
struct SecondView: View {
#Environment(\.dismiss) var dismiss
#ObservedObject var modelClass_viaObservedObject: ModelClass_ViaObservedObject
//global data in environment, not sent step by step view by view
#EnvironmentObject var modelClass_ViaEnvironment: ModelClass_ViaEnvironment
var body: some View {
VStack(spacing: 5) {
Text("Second View")
Button("change bool for everyone") {
modelClass_viaObservedObject.isOn.toggle()
dismiss()
}
TextField("send back", text: $modelClass_ViaEnvironment.message)
Text(modelClass_ViaEnvironment.message)
}
}
}
No, we use #State for view data like if a toggle isOn, which can either be a single value itself or a custom struct containing multiple values and mutating funcs. We pass it down the View hierarchy by declaring a let in the child View or use #Binding var if we need write access. Regardless of if we declare it let or #Binding whenever a different value is passed in to the child View's init, SwiftUI will call body automatically (as long as it is actually accessed in body that is).
#StateObject is for when a single value or a custom struct won't do and we need a reference type instead for view data, i.e. if persisting or syncing data (not using the new async/await though because we use .task for that). The object is init before body is called (usually before it is about to appear) and deinit when the View is no longer needed (usually after it disappears).
#EnvironmentObject is usually for the store object that holds model structs in #Published properties and is responsible for saving or syncing,. The difference is the model data is not tied to any particular View, like #State and #StateObject are for view data. This object is usually a singleton, one for the app and one with sample data for when previewing, because it should never be deinit. The advantage of #EnvironmentObject over #ObservedObject is we don't need to pass it down through each View as a let that don't need the object when we only need it further down the hierarchy. Note the reason it has to be passed down as a let and not #ObservedObject is then body would be needlessly called in the intermediate Views because SwiftUI's dependency tracking doesn't work for objects only value types.
Here is some sample code:
struct MyConfig {
var isOn = false
var message = ""
mutating func reset() {
isOn = false
message = ""
}
}
struct MyView: View {
#State var config = MyConfig() // grouping vars into their struct makes use of value semantics to track changes (a change to any of its properties is detected as a change to the struct itself) and offers testability.
var body: some View {
HStack {
ViewThatOnlyReads(config: config)
ViewThatWrites(config: $config)
}
}
}
struct ViewThatOnlyReads: View {
let config: MyConfig
var body: some View {
Text(config.isOn ? "It's on" : "It's off")
}
}
struct ViewThatWrites: View {
#Binding var config: MyConfig
var body: some View {
Toggle("Is On", isOn: $config.isOn)
}
}
If I have an ObservableObject like...
class Foo: ObservableObject {
#Published var value: Int = 1
func update() {
value = 1
}
}
And then a view like...
struct BarView: View {
#ObservedObject var foo: Foo
var body: some View {
Text("\(foo.value)")
.onAppear { foo.update() }
}
}
Does this cause the view to constantly refresh? Or does SwiftUI do something akin to removeDuplicates in the subscribers that it creates?
I imagine the latter but I've been struggling to find any documentation on this.
onAppear is called when the view is first brought on screen. It's not called again when the view is refreshed because a published property has updated, so your code here would just bump the value once, and update the view.
If you added something inside the body of view that updated the object, that would probably trigger some sort of exception, which I now want to try.
OK, this:
class Huh: ObservableObject {
#Published var value = 1
func update() {
value += 1
}
}
struct TestView: View {
#StateObject var huh = Huh()
var body: some View {
huh.update()
return VStack {
Text("\(huh.value)")
}.onAppear(perform: {
huh.update()
})
}
}
Just puts SwiftUI into an infinite loop. If I hadn't just bought a new Mac, it would have crashed by now :D
When I update a binding property from an array in a pushed view 2+ layers down, the navigation pops back instantly after a change to the property.
Xcode 13.3 beta, iOS 15.
I created a simple demo and code is below.
Shopping Lists
List Edit
List section Edit
Updating the list title (one view deep) is fine, navigation stack stays same, and changes are published if I return. But when adjusting a section title (two deep) the navigation pops back as soon as I make a single change to the property.
I have a feeling I'm missing basic fundamentals here, and I have a feeling it must be related to the lists id? but I'm struggling to figure it out or work around it.
GIF
Code:
Models:
struct ShoppingList {
let id: String = UUID().uuidString
var title: String
var sections: [ShoppingListSection]
}
struct ShoppingListSection {
let id: String = UUID().uuidString
var title: String
}
View Model:
final class ShoppingListsViewModel: ObservableObject {
#Published var shoppingLists: [ShoppingList] = [
.init(
title: "Shopping List 01",
sections: [
.init(title: "Fresh food")
]
)
]
}
Content View:
struct ContentView: View {
var body: some View {
NavigationView {
ShoppingListsView()
}
}
}
ShoppingListsView
struct ShoppingListsView: View {
#StateObject private var viewModel = ShoppingListsViewModel()
var body: some View {
List($viewModel.shoppingLists, id: \.id) { $shoppingList in
NavigationLink(destination: ShoppingListEditView(shoppingList: $shoppingList)) {
Text(shoppingList.title)
}
}
.navigationBarTitle("Shopping Lists")
}
}
ShoppingListEditView
struct ShoppingListEditView: View {
#Binding var shoppingList: ShoppingList
var body: some View {
Form {
Section(header: Text("Title")) {
TextField("Title", text: $shoppingList.title)
}
Section(header: Text("Sections")) {
List($shoppingList.sections, id: \.id) { $section in
NavigationLink(destination: ShoppingListSectionEditView(section: $section)) {
Text(section.title)
}
}
}
}
.navigationBarTitle("Edit list")
}
}
ShoppingListSectionEditView
struct ShoppingListSectionEditView: View {
#Binding var section: ShoppingListSection
var body: some View {
Form {
Section(header: Text("Title")) {
TextField("title", text: $section.title)
}
}
.navigationBarTitle("Edit section")
}
}
try this, works for me:
struct ContentView: View {
var body: some View {
NavigationView {
ShoppingListsView()
}.navigationViewStyle(.stack) // <--- here
}
}
Try to make you object confirm to Identifiable and return value which unique and stable, for your case is ShoppingList.
Detail view seems will pop when object id changed.
The reason your stack is popping back to the root ShoppingListsView is that the change in the list is published and the root ShoppingListsView is registered to listen for updates to the #StateObject.
Therefore, any change to the list is listened to by ShoppingListsView, causing that view to be re-rendered and for all new views on the stack to be popped in order to render the root ShoppingListsView, which is listening for updates on the #StateObject.
The solution to this is to change the #StateObject to #EnvironmentObject
Please refactor your code to change ShoppingListsViewModel to use an #EnvironmentObject wrapper instead of a #StateObject wrapper
You may pass the environment object in to all your child views and also add a boolean #Published flag to track any updates to the data.
Then your ShoppingListView would look as below
struct ShoppingListsView: View {
#EnvironmentObject var viewModel = ShoppingListsViewModel()
var body: some View {
List($viewModel.shoppingLists, id: \.id) { $shoppingList in
NavigationLink(destination: ShoppingListEditView(shoppingList: $shoppingList)) {
Text(shoppingList.title)
}
}
.navigationBarTitle("Shopping Lists")
}
}
Don't forget to pass the viewModel in to all your child views.
That should fix your problem.
I have this rather common situation where a List is displaying some items from an #ObservedObject data store as NavigationLinks.
On selecting a NavigationLink a DetailView is presented. This view has a simple Toggle conected to a #Published var on its ViewModel class.
When the DetailView appears (onAppear:) its View Model sets the published var that controls the Toggle to true and also triggers an async request that will update the main data store, causing the List in the previous screen to update too.
The problem is that when this happens (the List is reloaded from an action triggered in the Detail View) multiple instances of the DetailViewModel seem to retained and the DetailView starts receiving events from the wrong Publishers.
In the first time the Detail screen is reached the behaviour is correct (as shown in the code below), the toggle is set to true and the store is updated, however, on navigating back to the List and then again to another DetailView the toggle is set to true on appearing but this time when the code that reloads the store executes, the toggle is set back to false.
My understanding is that when the List is reloaded and a new DetailView and ViewModel are created (as the destination of the NavigationLink) the initial value from the isOn variable that controls the Toggle (which is false) is somehow triggering an update to the Toggle of the currently displayed screen.
Am I missing something here?
import SwiftUI
import Combine
class Store: ObservableObject {
static var shared: Store = .init()
#Published var items = ["one", "two"]
private init() { }
}
struct ContentView: View {
#ObservedObject var store = Store.shared
var body: some View {
NavigationView {
List(store.items, id: \.self) { item in
NavigationLink(item, destination: ItemDetailView())
}
}
}
}
struct ItemDetailView: View {
#ObservedObject var viewModel = ItemDetailViewModel()
var body: some View {
VStack {
Toggle("A toggle", isOn: $viewModel.isOn)
Text(viewModel.title)
Spacer()
} .onAppear(perform: viewModel.onAppear)
}
}
class ItemDetailViewModel: ObservableObject {
#ObservedObject var store: Store = .shared
#Published var isOn = false
var title: String {
store.items[0]
}
func onAppear() {
isOn = true
asyncOperationThatUpdatesTheStoreData()
}
private func asyncOperationThatUpdatesTheStoreData() {
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
self?.store.items = ["three", "four"]
}
}
}
You're controlling lifetimes and objects in a way that's not a pattern in this UI framework. The VieModel is not going to magically republish a singleton value type; it's going to instantiate with that value and then mutate its state without ever checking in with the shared instance again, unless it is rebuilt.
class Store: ObservableObject {
static var shared: Store = .init()
struct ContentView: View {
#ObservedObject var store = Store.shared
struct ItemDetailView: View {
#ObservedObject var viewModel = ItemDetailViewModel()
class ViewModel: ObservableObject {
#Published var items: [String] = Store.shared.items
There are lots of potential viable patterns. For example:
Create class RootStore: ObservableObject and house it as an #StateObject in App. The lifetime of a StateObject is the lifetime of that view hierarchy's lifetime. You can expose it (a) directly to child views by .environmentObject(store) or (b) create a container object, such as Factory that vends ViewModels and pass that via the environment without exposing the store to views, or (c) do both a and b.
If you reference the store in another class, hold a weak reference weak var store: Store?. If you keep state in that RootStore on #Published, then you can subscribe directly to that publisher or to the RootStore's own ObjectWillChangePublisher. You could also use other Combine publishers like CurrentValueSubject to achieve the same as #Published.
For code examples, see
Setting a StateObject value from child view causes NavigationView to pop all views
SwiftUI #Published Property Being Updated In DetailView
I have a view and a viewModel that should update the ListView when users are added to the user array. I can verify that users are being added, yet the ObservedObject is not updating.
I have a search bar that lets you search users and then updates user array in the ViewModel which is supposed to update the View but it doesn't.
ViewModel
class UsersViewModel: ObservableObject {
#Published var users: [User] = []
#Published var isLoading = false
var searchText: String = ""
func searchTextDidChange() {
isLoading = true
API.User.searchUser(text: searchText) { (users) in
self.isLoading = false
self.users = users
}
// confirmed that users has data now at this point
}
}
View
struct UsersView: View {
#ObservedObject var usersViewModel = UsersViewModel()
var body: some View {
VStack() {
SearchBarView(text: $usersViewModel.searchText, onSearchButtonChanged: usersViewModel.searchTextDidChange)
// Not getting called after usersViewModel.users gets data
if (usersViewModel.users.count > 0) {
Text(usersViewModel.users[0].username)
}
}
}
}
You are likely winding up with different UsersViewModel objects:
#ObservedObject var usersViewModel = UsersViewModel()
Since UsersView is a struct, this creates a new model every time the View is instantiated (which can happen very often, not just when the view appears). In iOS 14 there is #StateObject to combine State (which preserves information between View instantiations) with ObservedObject, but in iOS 13 I recommend passing in the ObservedObject if it's not a shared instance.
Try to update on main queue
API.User.searchUser(text: searchText) { (users) in
DispatchQueue.main.async {
self.isLoading = false
self.users = users
}
}
If your view is inside another view and you are not injecting the view model, consider using #StateObject.
This will not cause the object to be renewed every time the view is re-rendered.