SwiftUI provides the onChange
modifier, allowing developers to listen for changes in specific values within a view and execute corresponding actions when those values change. Intuitively, as long as a view is part of the currently visible branch of the view tree (active), the corresponding closure should be triggered when the observed value changes. However, in certain navigation scenarios, the onChange
modifier seems to become “selectively deaf,” inexplicably remaining silent even when the observed value changes. Is this a carefully designed feature by Apple, or a long-hidden code defect? This article aims to unveil this phenomenon and provide necessary caution to developers.
The Problem
A reader named Eric0625 left a comment on my blog, sharing a peculiar issue regarding the abnormal behavior of onChange
.
In his scenario, RootView
, MiddleView
, and TopView
each use onChange
to observe the same cross-view state value, which can be modified within TopView
.
When the navigation hierarchy is NavigationStack
-> RootView
-> TopView
, the onChange
closures in both RootView
and TopView
are triggered normally. However, when the navigation hierarchy changes to NavigationStack
-> RootView
-> MiddleView
-> TopView
, the onChange
in MiddleView
is not triggered.
Upon first seeing this comment, I instinctively thought it might be an issue with the code implementation. Based on my understanding of SwiftUI, regardless of how many layers the navigation stack has, each view at each level should be active and able to respond to state changes. Therefore, when the state changes, the corresponding onChange
closure in each view should be called.
However, after running and analyzing his code in detail, I confirmed that this unexpected phenomenon does indeed exist.
Where exactly is the problem?
Searching for Clues
I first suspected whether this was a bug in a specific version. By testing under Xcode 15 and Xcode 16 across iOS 15 to iOS 18 systems, I found that this is a long-standing and widespread phenomenon.
Next, I began to investigate whether it was caused by the navigation method. After testing with the traditional NavigationView
and various programmatic navigation methods suitable for NavigationStack
, I confirmed that this abnormal phenomenon always exists, regardless of the navigation method used.
Finally, I turned my attention to the declaration and response of the state. Whether changing the state passing method to a step-by-step approach or replacing ObservableObject
with Observation
, the problem remained unsolved.
Through systematic elimination, we can ascertain that factors like system version, navigation logic, and state declaration methods are not the root cause. Comparing the two scenarios, the only difference is the addition of a MiddleView
, causing a change in the number of navigation layers. The issue might lie here.
To facilitate in-depth testing and reproduction, I reorganized the test code to make it clearer and more manageable:
class ViewModel: ObservableObject {
@Published var count = 0
}
struct Root: View {
@StateObject private var viewModel = ViewModel()
var body: some View {
NavigationStack {
SubView(level: 1)
.navigationDestination(for: Int.self) { value in
SubView(level: value)
}
}
.environmentObject(viewModel)
}
}
struct SubView: View {
let level: Int
@EnvironmentObject var viewModel: ViewModel
var body: some View {
VStack {
// Verify the view's responsiveness to viewModel.count changes by printing update
let _ = print("Level \(level) Updated")
Button("Count++") {
viewModel.count += 1
}
NavigationLink(value: level + 1) {
Text("Goto Next Level")
}
}
.buttonStyle(.borderedProminent)
.navigationTitle(Text("Level: \(level)"))
// Use onChange in each level to respond to viewModel.count changes
.onChange(of: viewModel.count){ _ in
print("Level *\(level)* onChanged")
}
}
}
This code allows us to easily build arbitrary navigation levels. By clicking “Goto Next Level”, we can enter new levels and display the current level number; clicking “Count++” increments viewModel.count
, which intuitively should trigger the onChange
closures in all views.
The Pattern of onChange
Running the above code, when the navigation levels are one or two layers deep, both the update
and onChange
are triggered as we expect.
However, when the navigation levels extend beyond two layers (3+ layers), a significant change occurs. In the navigation stack, all levels’ update
are triggered, but only the onChange
closures in the bottom-most and top-most views are called.
If we consider the pattern that “only the onChange
in the bottom-most and top-most views are triggered,” we find that this model also applies to navigation stack scenarios with one or two layers.
task(id:)
and onReceive
In many scenarios, developers often use task(id:)
as an alternative to the combination of onAppear
+ onChange
. After replacing onChange
with task(id:)
in the code, a new abnormal phenomenon emerges.
.task(id: viewModel.count){
print("Level *\(level)* onChanged in task(id:)")
}
In the navigation stack, when the observed value changes, only the topmost view’s task(id:)
will be triggered.
Considering that we are currently using ObservableObject
to declare the state, what would happen if we use onReceive
to respond to state changes? Replacing task(id:)
with onReceive
:
.onReceive(viewModel.$count){ _ in
print("Level *\(level)* receive count notification")
}
Unlike onChange
and task(id:)
, the onReceive
in all levels of the navigation stack accurately responds to each notification generated by the change, which fully meets our expectations.
Intentional Design or Bug?
By now, we can summarize the response patterns of views at different levels in the navigation stack based on the phenomena described above:
- Only the
onChange
in the bottom-most and top-most views are triggered. - When the observed value changes, only the
task(id:)
in the top-most view is triggered. - The
onReceive
in all levels are triggered.
Although we have clarified these behavior patterns, a fundamental question still lingers: Is this an intentional design by Apple (By Design), or a long-standing technical flaw?
When I thought this might be a design behavior similar to SwiftUI Only Retains the Top-Level State of ForEach Subviews in Lazy Containers, I retested the above code as a macOS application on macOS 15.1.
In macOS, regardless of how many levels the navigation stack has, the onChange
and task(id:)
in each level are called as expected. This makes the abnormal phenomenon more like a long-standing bug in the iOS system.
I have reported this issue to Apple (FB15963726). However, regardless of the final investigation result, for developers, when building multi-layer navigation containers (especially on iOS systems), it is necessary to anticipate and address the possibility that onChange
and task(id:)
may not be triggered, to avoid falling into technical dilemmas caused by this issue.