SwiftUI NavigationLink pops automatically which is unexpected
Asked Answered
I

3

8

I am having some issues with a NavigationLink on an iPad with split view (landscape). Here is an example:

screen recording

Here is the code to reproduce the issue:

import SwiftUI

final class MyEnvironmentObject: ObservableObject {
    @Published var isOn: Bool = false
}

struct ContentView: View {
    @EnvironmentObject var object: MyEnvironmentObject

    var body: some View {
        NavigationView {
            NavigationLink("Go to FirstDestinationView", destination: FirstDestinationView(isOn: $object.isOn))
        }
    }
}

struct FirstDestinationView: View {
    @Binding var isOn: Bool

    var body: some View {
        NavigationLink("Go to SecondDestinationView", destination: SecondDestinationView(isOn: $isOn))
    }
}

struct SecondDestinationView: View {
    @Binding var isOn: Bool

    var body: some View {
        Toggle(isOn: $isOn) {
            Text("Toggle")
        }
    }
}

// Somewhere in SceneDelegate
ContentView().environmentObject(MyEnvironmentObject())

Does anyone know a way to fix this? An easy fix is to disable split view, but that is not possible for me.

Interrogatory answered 31/12, 2019 at 14:24 Comment(3)
@Asperi Please explain what's wrong in the code. I don't understand why this is happening. The screen should not pop when toggling the switch, right?Interrogatory
Sorry, I was wrong, misread the code - looks like List caching problem.Oarlock
@Oarlock Just updated the question without using a List. Any other ideas what the issue could be?Interrogatory
O
6

Ok, here is my investigation results (tested with Xcode 11.2) and below is the code that works.

In iPad NavigationView got into Master/Details style, so ContentView having initial link is active and process bindings update from environmentObject, so refresh, which result in activating link of details view via same binding, thus corrupting navigation stack. (Note: this is absent in iPhone due to stack style, which deactivates root view).

So, probably this is workaround, but works - the idea is not to pass binding from view to view, but use environmentObject directly in final view. Probably this will not always be a case, but anyway in such scenarios it is needed to avoid root view refresh, so it should not have same binding in body. Something like that.

final class MyEnvironmentObject: ObservableObject {
    @Published var selection: Int? = nil
    @Published var isOn: Bool = false
}

struct ContentView: View {
    @EnvironmentObject var object: MyEnvironmentObject

    var body: some View {
        NavigationView {
            List {
                NavigationLink("Go to FirstDestinationView", destination: FirstDestinationView())
            }
        }
    }
}

struct FirstDestinationView: View {

    var body: some View {
        List {
            NavigationLink("Go to SecondDestinationView", destination: SecondDestinationView())
        }
    }
}

struct SecondDestinationView: View {
@EnvironmentObject var object: MyEnvironmentObject

    var body: some View {
        VStack {
            Toggle(isOn: $object.isOn) {
                Text("Toggle")
            }
        }
    }
}
Oarlock answered 31/12, 2019 at 15:49 Comment(2)
Thanks for looking into this! I understand why this would work. Unfortunately this is not a solution for me. That is because the master view needs to be able to "refresh" when the user interacts with it.Interrogatory
The code works great. However, the same issue reproduces if I add another NavigationLink which uses EnvironmentObject variables to ContentView. For example, NavigationLink(tag: 0, selection: $object.selection, destination: EmptyView.init) { EmptyView() }Merrow
C
5

When something within EnvironmentObject changes, it will render the whole view again including NavigationLink. That's the root cause of automatic pop back.

My research on it:

  • OK on iOS 15 (seems Apple fixed)
  • Still broken on iOS 14
  • The reason why "This bug went away when I dropped the @EnvironmentObject and went with an @ObservedObject instead." @Jon Vogel mentioned is ObservedObject is a local state, which will not be affected by other views while EnvironmentObject is global state and can change from any other remote views.
Chianti answered 30/9, 2021 at 21:31 Comment(0)
S
1

You need can use isDetailLink(_:) to fix that, e.g.

struct FirstDestinationView: View {
    @Binding var isOn: Bool

    var body: some View {
        NavigationLink("Go to SecondDestinationView", destination: SecondDestinationView(isOn: $isOn))
        .isDetailLink(false)
    }
}
Submerged answered 8/4, 2022 at 9:49 Comment(0)

© 2022 - 2025 — McMap. All rights reserved.