SwiftUI Navigation Stack pops back on ObservableObject update

I have a starting view that contains the StateObject created from an Observable Object.

Code Block language
struct PostLoginView: View {
  @StateObject var userLoader = FirestoreUser(uid: Auth.auth().currentUser!.uid, getUpdates: true)
var body: some View {
    TabView(selection: $currentTab) {
      NavigationView{
        UserProfileView(currentTab: $currentTab, userLoader: userLoader, postsLoader: postsLoader)
        .environmentObject(userSettings)
      }

This gets passed to other views where it is observed.

Code Block language
struct UserProfileView: View {
  @ObservedObject var userLoader: FirestoreUser


Code Block language
Group{
            HStack {
              Spacer()
              NavigationLink(destination: ProfilePhotoUpdateView(userLoader: self.userLoader).environmentObject(self.userSettings)) {
                Image(systemName: "pencil").padding(.horizontal).font(Font.title.weight(.bold))
                  .foregroundColor(buttonStartColor)
              }
            }


This chain can continue for a few more links

If the model userLoader is updated in any way, the stack pops back automatically to the first view. This is not the desired behavior. How can I keep the current child view when the model is updated?


Post not yet marked as solved Up vote post of ardeploy Down vote post of ardeploy
15k views
  • This is a bug that kicked with v14.5 and is still there several months later with version 15.1. @Apple - Can you let us know this is on your radar?

Add a Comment

Replies

Having the same issue, and it's a real nusance. Is there some sort of use pattern to avoid this.?
  • If possible, instantiate the ObservableObject as deep into the navigation hierarchy as you can go. This works only for a subsequent child screen,

Add a Comment
I ran into the same issue, which seems to be because when a dataupdate is published from an ObservableObject, the view listening for it will re-render, and therefore tear down the viewstack that sits on top of it. It would be nice to have an apple-provided pause/resume publish functionality of the ObservableObject in order to allow a view down in the viewstack to update its datasource in the background without tearing down the viewstack on top of it. I have not found such functionality, but implemented something that so far handles it quite nicely.

This is particularly relevant when you have views that is built upon several datasources, so that one part of the view is ready for further interaction and navigation while other parts of the view is still loading, and I do not want wherever I navigated to to pop just because another part of the parent-view datasources finished loading.

The way I handled it was:
In my ObservableObject, I removed the @Published keyword for the variables in question in order to avoid the auto-publishing of updates.
In addition I added a private "visible" var, and functions for onAppear and onDisappear which sets the visibility variable accordingly.

Whenever my data should be loaded/updated, I check the visibility variable in order to determine if changes are to be published. If yes, then call objectWillChange.send() which will publish that there is a change in the observable object, and prepare the view owning the observable to fetch new data from it.
Now, in that view, I added .onAppear and .onDisappear, where I call the onAppear and onDisappear of the observable.

In the onAppear of the ObservableObject, I also call the objectWillChange.send() in order to trigger a publish of the data inside.

With this approach, I am able to allow an ObservableObject to refresh its datasource in the background while another view is pushed on top of it without popping the viewstack when data is loaded. But as soon as the view in question once again becomes visible, it will be populated with fresh data.
  • I'm still having the same issue even if the data is fully loaded and there are not changes after navigation link is activated on parent view. Before iOS 15.0 all was well-working. Any help?

    Note: It is happening only when the child destination when the observableObject is being mutated is not the only one in the navigation stack.

Add a Comment
Update: Made a small extention that so far looks to be handling it. Not thoroughly tested though, so might need some rework. But the general idea is

Extend ObservableObject

Code Block language
class PausableObservableObject: ObservableObject {
private var isPaused: Bool = false
private var hasPendingUpdates = false
var onResume: (() -> ())? = nil
func publishWillUpdate() {
if (!isPaused) {
self.objectWillChange.send()
} else {
hasPendingUpdates = true
}
}
func pause() {
isPaused = true
}
func resume() {
isPaused = false
if hasPendingUpdates {
hasPendingUpdates = false
self.objectWillChange.send()
}
onResume?()
}
}


And make a viewModifier that is visibility aware:
Code Block language
struct VisibilityAwareObservables: ViewModifier {
let observables: [PausableObservableObject]
func body(content: Content) -> some View {
AnyView(content)
.onAppear {
for observable in observables {
observable.resume()
}
}
.onDisappear {
for observable in observables {
observable.pause()
}
}
}
}
extension View {
func visibilityAwareObservables(observables: [PausableObservableObject]) -> some View {
ModifiedContent(content: self, modifier: VisibilityAwareObservables(observables: observables))
}
}


Now make your observableObject an instance of PausableObservableObject and make sure that whenever data to be published is changed, you call
Code Block language
publishWillUpdate()


and simply pass this object along with any other PausableObservableObject into the viewModifier visibilityAwareObservables

Example:

Code Block language
struct ExampleView: View {
@ObservedObject var datasource = MyDatasourceObservable()
/* MyDatasourceObservable extends PausableObservableObject */
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(datasource.somePublishedParameter)
}
.visibilityAwareObservables(observables: [datasource])
}
}




  • @Ole-Kristian, this has worked beautifully for me (iOS 15.4/Xcode 13.4.1). I had a less elegant solution and replaced it with yours.🙏🏻

Add a Comment

Having the same issue on iOS 15, before updating it was working fine (on iOS 14.5), no popping back to the parent view

I fixed the issue by using a button with a navigation link instead of just NavigationLink and now it doesn't pop to the root view when the parent's state changes. Here is the code:

struct CustomNavigationLink<Content, Destination>: View where Destination: View, Content: View {
  let destination: Destination?
  let content: () -> Content

  @State private var isActive = false

  init(destination: Destination, @ViewBuilder content: @escaping () -> Content) {
    self.content = content
    self.destination = destination
  }

  var body: some View {
    ZStack {
      NavigationLink(destination: destination, isActive: $isActive) {}

      Button(action: {
        self.isActive = true
      }) {
        content()
      }
    }
  }
}
  • @omerbas, what about invisible/empty view navigation with tag and selection parameters?

    Since iOS 15.0.1 this bug is causing our app to critically fail...

  • @omerbas this didn't work for me unfortunately

Add a Comment

Changing the NavigationView's style to .stack seems to do the trick for me. This has solved other navigation-related problems in the past for me as well.

You need to apply the modifier directly to the NavigationView, not to its contents:

NavigationView {
    ShoppingListsView()
}.navigationViewStyle(.stack) 
  • @martin your trick works for me too! thanks!

  • This works well.

  • Thanks, this worked perfectly for me.

isDetailLink(false) on the NavigationLink will fix this problem

Post not yet marked as solved Up vote reply of malc Down vote reply of malc

On iOS 15.1.1 this works.

NavigationLink(
  destination: List{
    // result
    // ForEach(){ item in ...
  }
)
.isDetailLink(false)
  • This works for me. From apple documents, this works because " If isDetailLink is true, performing the link in the primary column sets the contents of the secondary (detail) column to be the link’s destination view. If isDetailLink is false, the link navigates to the destination view within the primary column. " .isDetailLink(false) kind of force to set it to the "stack" mode.

    Therefore, the more appropriate way should be

    NavigationView { ShoppingListsView() }.navigationViewStyle(.stack)

    which explicitly set the mode to .stack

  • Note: For this to work for me, the addition of .isDetailLink(false) had to be done on the first instance of your navigation view path. Put another way if you have multiple navigations i.e. View1( with a "NavigationLink")-> View2(with a "NavigationLink")-> View3; adding .isDetailLink(false) on the first view (Not View2) ...fixed it. I'm unsure why using .navigationViewStyle(.stack) did not work in my app. I've seen the recommendation here and elsewhere but it did not solve the issue.

Add a Comment

The new NavigationStack in SwiftUI for iOS 16 doesn't seem to have this issue anymore.

  • Yes it does!

  • Definitely broken in iOS16. It appears (from my reading) that this is the way things work. Any change to an ObservedObject outside of a NavigationStack or sheet will cause the parent view to be rebuilt, which in turn wipes the stack or sheet.

Add a Comment

having the same issue with snap back however, my app snaps back from a nav link when a textfield on the nav link view is selected. none of the above worked for me.

What a weird and annoying issue this is, wasted a day and nothing seem to work.

  • Same problem. Tried it all, days now. Deconstructed 1/2 the app. Nothing works. IOS 15 and 16 beta, Xcode 13 and 14 beta. Custom navigation is just poorly supported. Only thing that’s reliable is a very strict oob Apple master detail navigation with back button. Really, really frustrating.

Add a Comment

This is because your observable object is being used by both a view that is hosting a navigation view, and one of the children being presented by a navigation view.

So when the child updates the observable object, it causes the parent to reload. Which in turn causes its navigation stack to be drawn from scratch as well.

the solution is to decouple the parent from the observable object. So it’s not reloading each time it changes.

A technique I use is to wrap an observable object inside another. What this does it it allows me to inject them via @EnvironmentObject, without causing the views to reload unnecessarily.

class Dependency<T: ObservableObject>: ObservableObject {
    let observable: T

    init(observable: T) {
        self.observable = observable
    }
}
  • @Xaxxus can you explain how you did this exactly? Trying to figure out how to do this. For context:

    I'm using Swift Realm by Mongo DBI have a HomeView in which I display a list of Entry object (this Entry object is the ObservableObject, but I have a list of them)I have a "detailed view" called EntryView which renders individual entries (this takes in 1 Entry)

    Any update to the Entry in EntryView pops the navigation back to HomeView

Add a Comment

In case anyone else having a similar problem. Using following with NavigationStack solved the problem for me. As explained in these documents,

https://developer.apple.com/documentation/swiftui/navigationlink

https://developer.apple.com/documentation/swiftui/migrating-to-new-navigation-types

NavigationLink {
    FolderDetail(id: workFolder.id)
} label: {
    Label("Work Folder", systemImage: "folder")
}

change NavigationView { content } to NavigationStack { content }