I'm having a problem in my app where the navigation stack is getting erased when certain parts of the view are re-rendered. I've narrowed the problem down to this sample app:
import SwiftUI
let demonstrateProblem = false
@main
struct NavStackApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
struct ContentView: View {
@State var model = ValueProducer()
var body: some View {
NavigationView {
DivisorCheckView(model: model)
Text("Choose a divisor (detail view)")
}
}
}
struct DivisorCheckView: View {
@ObservedObject var model: ValueProducer
var divisor = 2
var body: some View {
VStack(spacing: 12) {
Text("\(model.value) \(model.value % divisor == 0 ? "is" : "is not") divisible by \(divisor)")
if demonstrateProblem ? Bool.random() : true {
otherDivisorBody(divisor: divisor + 1)
}
else {
otherDivisorBody(divisor: divisor + 1)
}
}
.navigationTitle("Divisibility by \(divisor)")
}
@ViewBuilder
func otherDivisorBody(divisor num: Int) -> some View {
NavigationLink(destination: DivisorCheckView(model: model, divisor: num)) {
Text("Check divisibility by \(num)")
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
ContentView()
}
}
}
class ValueProducer: ObservableObject {
@Published var value = 0
init() {
cycle()
}
private func cycle() {
value = type(of: value).random(in: (10...99))
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] () -> Void in
self?.cycle()
}
}
}
If you run this app as-is, the navigation stack operates as expected: every time you tap on a link to a subview, the screen continues to update every 2 seconds as a new number is generated — and the view stack never pops on its own.
If you change demonstrateProblem
to true
, the behavior I'm seeing in my larger app presents itself here in this demo app: the navigation stack pops on its own.
What seems to be happening is that SwiftUI is sensitive to which fork of the if
statement is being executed. When a different fork is being executed, the navigation stack is completely reset to that layer in the navigation stack.
I know SwiftUI does sneaky things such as using view hierarchy deltas to incrementally create and destroy view instances; it feels like SwiftUI is deciding that part of the view hierarchy was completely destroyed — and if that were true, then of course automatically unwinding the navigation stack to that point makes total sense.
I've seen recommendations on forums suggesting that we should disable observer notifications on views when the view is no longer active, but that has an annoying side effect of preventing those views from updating (and then I have to manually trigger a potentially unnecessary update when the user pops the navigation stack for real, which feels overly complicated).
What is going on here? Is there a development pattern I should follow that doesn't trick SwiftUI into thinking that the old view hierarchy was destroyed?