Intentional Design or Technical Flaw? The Anomaly of onChange in SwiftUI Multi-Layer Navigation

Published on

Get weekly handpicked updates on Swift and SwiftUI!

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:

Swift
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.

Swift
.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:

Swift
.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.

Weekly Swift & SwiftUI insights, delivered every Monday night. Join developers worldwide.
Easy unsubscribe, zero spam guaranteed