SwiftUI conditional causing an MVVM view's navigationTitle to not update [duplicate] - ios

This question already has answers here:
What is the difference between #StateObject and #ObservedObject in child views in swiftUI
(3 answers)
Closed 3 months ago.
Here's a hypothetical master/detail pair of SwiftUI views that presents a button which uses NavigationLink:value:label: to navigate to a child view. The child view uses MVVM and has a .navigationTitle modifier that displays a placeholder until the real value is set (by a network operation that is omitted for the sake of brevity).
Upon first launch, tapping the button does navigate to the child view, but the "Loading child..." navigationTitle placeholder never changes to the actual value of "Alice" despite being set in the viewmodel's loadChild() method. If you navigate back and tap the button again, all subsequent navigations do set the navigationTitle correctly.
However, the child view has an if condition. If that if condition is replaced with Text("whatever") and the app is re-built and re-launched, the navigationTitle gets set properly every time. Why does the presence of an if condition inside the view affect the setting of the view's navigationTitle, and only on the first use of navigation?
import SwiftUI
// MARK: Data Structures
struct AppDestinationChild: Identifiable, Hashable {
var id: Int
}
struct Child: Identifiable, Hashable {
var id: Int
var name: String
}
// MARK: -
struct ChildView: View {
#ObservedObject var vm: ChildViewModel
init(id: Int) {
vm = ChildViewModel(id: id)
}
var body: some View {
VStack(alignment: .center) {
// Replacing this `if` condition with just some Text()
// view makes the navigationTitle *always* set properly,
// including during first use.
if vm.pets.count <= 0 {
Text("No pets")
} else {
Text("List of pets would go here")
}
}
.navigationTitle(vm.child?.name ?? "Loading child...")
.task {
vm.loadChild()
}
}
}
// MARK: -
extension ChildView {
#MainActor class ChildViewModel: ObservableObject {
#Published var id: Int
#Published var child: Child?
#Published var pets = [String]()
init(id: Int) {
self.id = id
}
func loadChild() {
// Some network operation would happen here to fetch child details by id
self.child = Child(id: id, name: "Alice")
}
}
}
// MARK: -
struct ContentView: View {
var body: some View {
NavigationStack {
NavigationLink(value: AppDestinationChild(id: 42), label: {
Text("Go to child view")
})
.navigationDestination(for: AppDestinationChild.self) { destination in
ChildView(id: destination.id)
}
}
}
}

The point of .task is to get rid of the need for a reference type for async code, I recommend you replace your state object with state, e.g.
#State var child: Child?
.task {
child = await Child.load()
}
You could also catch an exception and have another state for an error message.

Related

How to keep SwiftUI from creating additional StateObjects in this custom page view?

Abstract
I'm creating an app that allows for content creation and display. The UX I yearn for requires the content creation view to use programmatic navigation. I aim at architecture with a main view model and an additional one for the content creation view. The problem is, the content creation view model does not work as I expected in this specific example.
Code structure
Please note that this is a minimal reproducible example.
Suppose there is a ContentView: View with a nested AddContentPresenterView: View. The nested view consists of two phases:
specifying object's name
summary screen
To allow for programmatic navigation with NavigationStack (new in iOS 16), each phase has an associated value.
Assume that AddContentPresenterView requires the view model. No workarounds with #State will do - I desire to learn how to handle ObservableObject in this case.
Code
ContentView
struct ContentView: View {
#EnvironmentObject var model: ContentViewViewModel
var body: some View {
VStack {
NavigationStack(path: $model.path) {
List(model.content) { element in
Text(element.name)
}
.navigationDestination(for: Content.self) { element in
ContentDetailView(content: element)
}
.navigationDestination(for: Page.self) { page in
AddContentPresenterView(page: page)
}
}
Button {
model.navigateToNextPartOfContentCreation()
} label: {
Label("Add content", systemImage: "plus")
}
}
}
}
ContentDetailView (irrelevant)
struct ContentDetailView: View {
let content: Content
var body: some View {
Text(content.name)
}
}
AddContentPresenterView
As navigationDestination associates a destination view with a presented data type for use within a navigation stack, I found no better way of adding a paged view to be navigated using the NavigationStack than this.
extension AddContentPresenterView {
var contentName: some View {
TextField("Name your content", text: $addContentViewModel.contentName)
.onSubmit {
model.navigateToNextPartOfContentCreation()
}
}
var contentSummary: some View {
VStack {
Text(addContentViewModel.contentName)
Button {
model.addContent(addContentViewModel.createContent())
model.navigateToRoot()
} label: {
Label("Add this content", systemImage: "checkmark.circle")
}
}
}
}
ContentViewViewModel
Controls the navigation and adding content.
class ContentViewViewModel: ObservableObject {
#Published var path = NavigationPath()
#Published var content: [Content] = []
func navigateToNextPartOfContentCreation() {
switch path.count {
case 0:
path.append(Page.contentName)
case 1:
path.append(Page.contentSummary)
default:
fatalError("Navigation error.")
}
}
func navigateToRoot() {
path.removeLast(path.count)
}
func addContent(_ content: Content) {
self.content.append(content)
}
}
AddContentViewModel
Manages content creation.
class AddContentViewModel: ObservableObject {
#Published var contentName = ""
func createContent() -> Content {
return Content(name: contentName)
}
}
Page
Enum containing creation screen pages.
enum Page: Hashable {
case contentName, contentSummary
}
What is wrong
Currently, for each page pushed onto the navigation stack, a new StateObject is created. That makes the creation of object impossible, since the addContentViewModel.contentName holds value only for the bound screen.
I thought that, since StateObject is tied to the view's lifecycle, it's tied to AddContentPresenterView and, therefore, I would be able to share it.
What I've tried
The error is resolved when addContentViewModel in AddContentPresenterView is an EnvironmentObject initialized in App itself. Then, however, it's tied to the App's lifecycle and subsequent content creations greet us with stale data - as it should be.
Wraping up
How to keep SwiftUI from creating additional StateObjects in this custom page view?
Should I resort to ObservedObject and try some wizardry? Should I just implement a reset method for my AddContentViewModel and reset the data on entering or quiting the screen?
Or maybe there is a better way of achieving what I've summarized in abstract?
If you declare #StateObject var addContentViewModel = AddContentViewModel() in your AddContentPresenterView it will always initialise new AddContentViewModel object when you add AddContentPresenterView in navigation stack. Now looking at your code and app flow I don't fill you need AddContentViewModel.
First, update your contentSummary of the Page enum with an associated value like this.
enum Page {
case contentName, contentSummary(String)
}
Now update your navigate to the next page method of your ContentViewModel like below.
func navigateToNextPage(_ page: Page) {
path.append(page)
}
Now for ContentView, I think you need to add VStack inside NavigationStack otherwise that bottom plus button will always be visible.
ContentView
struct ContentView: View {
#EnvironmentObject var model: ContentViewViewModel
var body: some View {
NavigationStack(path: $model.path) {
VStack {
List(model.content) { element in
Text(element.name)
}
.navigationDestination(for: Content.self) { element in
ContentDetailView(content: element)
}
.navigationDestination(for: Page.self) { page in
switch page {
case .contentName: AddContentView()
case .contentSummary(let name): ContentSummaryView(contentName: name)
}
}
Button {
model.navigateToNextPage(.contentName)
} label: {
Label("Add content", systemImage: "plus")
}
}
}
}
}
So now it will push destination view on basis of the type of the Page. So you can remove your AddContentPresenterView and add AddContentView and ContentSummaryView.
AddContentView
struct AddContentView: View {
#EnvironmentObject var model: ContentViewViewModel
#State private var contentName = ""
var body: some View {
TextField("Name your content", text: $contentName)
.onSubmit {
model.navigateToNextPage(.contentSummary(contentName))
}
}
}
ContentSummaryView
struct ContentSummaryView: View {
#EnvironmentObject var model: ContentViewViewModel
let contentName: String
var body: some View {
VStack {
Text(contentName)
Button {
model.addContent(Content(name: contentName))
model.navigateToRoot()
} label: {
Label("Add this content", systemImage: "checkmark.circle")
}
}
}
}
So as you can see I have used #State property in AddContentView to bind it with TextField and on submit I'm passing it as an associated value with contentSummary. So this will reduce the use of AddContentViewModel. So now there is no need to reset anything or you want face any issue of data loss when you push to ContentSummaryView.

SwiftUI #StateObject inside List rows

SwiftUI doesn't seem to persist #StateObjects for list rows, when the row is embedded inside a container like a stack or NavigationLink. Here's an example:
class MyObject: ObservableObject {
init() { print("INIT") }
}
struct ListView: View {
var body: some View {
List(0..<40) { _ in
NavigationLink(destination: Text("Dest")) {
ListRow()
}
}
}
}
struct ListRow: View {
#StateObject var obj = MyObject()
var body: some View {
Text("Row")
}
}
As you scroll down the list, you see "INIT" logged for each new row that appears. But scroll back up, and you see "INIT" logged again for every row - even though they've already appeared.
Now remove the NavigationLink:
List(0..<40) { _ in
ListRow()
}
and the #StateObject behaves as expected: exactly one "INIT" for every row, with no repeats. The ObservableObject is persisted across view refreshes.
What rules does SwiftUI follow when persisting #StateObjects? In this example MyObject might be storing important state information or downloading remote assets - so how do we ensure it only happens once for each row (when combined with NavigationLink, etc)?
Here is what documentation says about StateObject:
/// #StateObject var model = DataModel()
///
/// SwiftUI creates a new instance of the object only once for each instance of
/// the structure that declares the object.
and List really does not create new instance of row, but reuses created before and went offscreen. However NavigationLink creates new instance for label every time, so you see this.
Possible solution for your case is to move NavigationLink inside ListRow:
struct ListView: View {
var body: some View {
List(0..<40) { _ in
ListRow()
}
}
}
and
struct ListRow: View {
#StateObject var obj = MyObject()
var body: some View {
NavigationLink(destination: Text("Dest")) { // << here !!
Text("Row")
}
}
}
You can even separate them if, say, you want to reuse ListRow somewhere without navigation
struct LinkListRow: View {
#StateObject var obj = MyObject()
var body: some View {
NavigationLink(destination: Text("Dest")) {
ListRow(obj: obj)
}
}
}

SwiftUI NavigationLink push in onAppear immediately pops the view when using #ObservableObject

I want to programmatically be able to navigate to a link within a List of NavigationLinks when the view appears (building deep linking from push notification). I have a string -> Bool dictionary which is bound to a custom Binding<Bool> inside my view. When the view appears, I set the bool property, navigation happens, however, it immediately pops back. I followed the answer in SwiftUI NavigationLink immediately navigates back and made sure that each item in the List has a unique identifier, but the issue still persists.
Two questions:
Is my binding logic here correct?
How come the view pops back immediately?
import SwiftUI
class ContentViewModel: ObservableObject {
#Published var isLinkActive:[String: Bool] = [:]
}
struct ContentViewTwo: View {
#ObservedObject var contentViewModel = ContentViewModel()
#State var data = ["1", "2", "3"]
#State var shouldPushPage3: Bool = true
var page3: some View {
Text("Page 3")
.onAppear() {
print("Page 3 Appeared!")
}
}
func binding(chatId: String) -> Binding<Bool> {
return .init(get: { () -> Bool in
return self.contentViewModel.isLinkActive[chatId, default: false]
}) { (value) in
self.contentViewModel.isLinkActive[chatId] = value
}
}
var body: some View {
return
List(data, id: \.self) { data in
NavigationLink(destination: self.page3, isActive: self.binding(chatId: data)) {
Text("Page 3 Link with Data: \(data)")
}.onAppear() {
print("link appeared")
}
}.onAppear() {
print ("ContentViewTwo Appeared")
if (self.shouldPushPage3) {
self.shouldPushPage3 = false
self.contentViewModel.isLinkActive["3"] = true
print("Activating link to page 3")
}
}
}
}
struct ContentView: View {
var body: some View {
return NavigationView() {
VStack {
Text("Page 1")
NavigationLink(destination: ContentViewTwo()) {
Text("Page 2 Link")
}
}
}
}
}
The error is due to the lifecycle of the ViewModel, and is a limitation with SwiftUI NavigationLink itself at the moment, will have to wait to see if Apple updates the pending issues in the next release.
Update for SwiftUI 2.0:
Change:
#ObservedObject var contentViewModel = ContentViewModel()
to:
#StateObject var contentViewModel = ContentViewModel()
#StateObject means that changes in the state of the view model do not trigger a redraw of the whole body.
You also need to store the shouldPushPage3 variable outside the View as the view will get recreated every time you pop back to the root View.
enum DeepLinking {
static var shouldPushPage3 = true
}
And reference it as follows:
if (DeepLinking.shouldPushPage3) {
DeepLinking.shouldPushPage3 = false
self.contentViewModel.isLinkActive["3"] = true
print("Activating link to page 3")
}
The bug got fixed with the latest SwiftUI release. But to use this code at the moment, you will need to use the beta version of Xcode and iOS 14 - it will be live in a month or so with the next GM Xcode release.
I was coming up against this problem, with a standard (not using 'isActive') NavigationLink - for me the problem turned out to be the use of the view modifiers: .onAppear{code} and .onDisappear{code} in the destination view. I think it was causing a re-draw loop or something which caused the view to pop back to my list view (after approx 1 second).
I solved it by moving the modifiers onto a part of the destination view that's not affected by the code in those modifiers.

SwiftUI doesn't update UI with ObservedObject in nested NavigationLink destination

I currently have an app that's fetching data from an API, In the root view (let's call it Home) everything works as expected, in the second view (let's call it User View) everything works as expected but now on the third view (Team View) the ObservedObject for this view only is not working.
The strangest part is that if the user navigates directly to the Team View, again every thing works as expected.
Each view has it's own ObservedObject has the data being loaded belongs only to that view
The navigation between each view is made by the NavigationLink
Heres an exemple of how I'm doing the loading and navigation.
struct HomeView: View {
#ObservedObject var viewModel = HomeViewModel()
var body: some View {
VStack {
NavigationLink(destination: UserView(userId: viewModel.userId))
NavigationLink(destination: TeamView(teamId: viewModel.teamId))
}
}
}
struct TeamView: View {
#ObservedObject var viewModel = TeamViewModel()
#State var teamId: String = ""
var body: some View {
Text(viewModel.name)
.onAppear() { viewModel.loadData(id: teamId) }
}
}
struct UserView: View {
#ObservedObject var viewModel = UserViewModel()
#State var userId: String = ""
var body: some View {
VStack {
Text(viewModel.name)
NavigationLink(destination: TeamView(teamId: viewModel.teamId))
}
.onAppear() { viewModel.loadData(id: userId) }
}
}
From the example you can see that the function to load the data is in the view model and is loaded when the view appears
Everything works just fine but when I reach the 3rd level in the stack the data does not get updated in the view. I thought It might be the thread but I'm using DispatchQueue.main.async when the fetch is complete.
All necessary variables on the Model are marked as #Published
In sum the following flows work
HomeView -> TeamView
HomeView -> UserView
But this one on the last view it does load the data but it does not update the view
HomeView -> UserView -> TeamView
I replicated your code behaviour and the issue is due to fast navigation. Here is what's going on
if you would do
HomeView [tap] -> UserView -> [wait for user loaded] -> TeamView // no issue
but you do
HomeView [tap] -> UserView [tap] -> TeamView // got defect
The defect is because UserView is updated in background when the data got loaded, so body rebuilt, so link is recreated, so TeamView is reconstructed, but .onAppear is not called, because such kind of view is already on screen.
(I'm not sure if this is SwiftUI bug, because there is logic in such behaviour).
So here is a solution for this case. Tested with Xcode 11.5b.
struct TeamView: View {
#ObservedObject var viewModel = TeamViewModel()
var teamId: String // << state is not needed
init(teamId: String) {
self.teamId = teamId
viewModel.loadData(id: teamId) // initiate load here !!
}
var body: some View {
Text(viewModel.name)
}
}
struct UserView: View {
#ObservedObject var viewModel = UserViewModel()
#State var userId: String = ""
var body: some View {
VStack {
Text(viewModel.name)
// used DeferView to avoid in-advance constructions
NavigationLink(destination: DeferView { TeamView(teamId: viewModel.teamId) })
}
.onAppear() { viewModel.loadData(id: userId) }
}
}
DeferView is taken from this my post.

Passing a BindableObject to the views

From the Apple's sessions and Tutorial we have two options to pass BindableObject to the views.
Use declare BindableObject as a Source of truth in the top view in the hierarchy with #ObjectBinding wrapper and pass it to other views with #Binding declared.
Use declare BindableObject as a Source of truth in the top view in the hierarchy with #EnviromentObject wrapper, init top view with .enviroment(BindableObject) modifier and pass it to other views or with #Binding declared, or using #EnviromentObject(in this case BindableObject will be assigned automatically by SwiftUI and we do not need to pass it on init).
From Handling User Input tutorial if we have BindableObject with list of items in and we want to change one of the items on other view(or RowView or even separate screen) we need to:
Pass the BindableObject to the deeper view using any of the ways above.
Pass the selected item to this view.
Bind the property of the item with BindingView by finding the item in BindableObject list.
Some code to make the question clear:
Message model and BindableObject
struct Message: Identifiable {
var id: String
var toggle: Bool = true
}
class MessageStore: BindableObject {
let didChange = PassthroughSubject<MessageStore, Never>()
var messagesList: [Message] = testData {
didSet {
didChange.send(self)
}
}
}
MessageView that represents a list of items
struct MessagesView
: View {
#EnvironmentObject var messageStore: MessageStore
var body: some View {
NavigationView {
List(messageStore.messagesList) { message in
NavigationButton(destination: Text(message.id)) {
MessageRow(message: message)
}
}
.navigationBarTitle(Text("Messages"))
}
}
}
MessageRow that has a Toggle to update the state of particular item in out BindableObject
struct MessageRow: View {
#EnvironmentObject var tags: MessageStore
var message: Message
var messageIndex: Int {
tags.messagesList.firstIndex { $0.id == message.id }!
}
var body: some View {
Toggle(isOn: self.$tags.messagesList[self.messageIndex].toggle) {
Text("Test toogle")
}
}
}
This approach is shown in the tutorial I mentioned above.
Question:
I wanted to pass Message separately as a #Binding to work with it in the child view directly, but I wasn't able to implement this.
I became a bit confused. Is it proper way to pass to any view(that should handle bindings) both the BindableObject and selected item to bind later the item from BindableObject using index? Is there any other way that will allow to pass not a full BindableObject but a part of it and bind this part(it should be Source of truth), in our case this part is Message?
I think you are overcomplicating it. It seems that all you need is a toggle value in your message. Try this:
var body: some View {
NavigationView {
List(messageStore.messagesList) { message in
NavigationButton(destination: Text(message.id)) {
MessageRow(isTogged: $message.toggle)
}
}
.navigationBarTitle(Text("Messages"))
}
}
struct MessageRow: View {
#Binding var isToggled: Bool
var body: some View {
Toggle(isOn: self.isToggled) {
Text("Test toogle")
}
}
}

Resources