onAppear
is an extremely crucial lifecycle method in SwiftUI, used to inject key logic when a view is presented. Since view instances may be created and rebuilt frequently, developers often choose to prepare data and perform initialization operations within these methods. In theory, the timing of these lifecycle method calls should be predictable and consistent. However, in certain specific scenarios, onAppear
may be called unexpectedly and unnecessarily. This not only can lead to performance overhead but also may cause uncontrollable changes in the application’s state. This article will uncover this easily overlooked SwiftUI behavior trap and provide temporary countermeasures.
Two Issues
Recently, two posts have appeared consecutively on the Apple Developer Forums, both reflecting the same symptom: onAppear
being called abnormally.
Below is the simplified code for Issue 1:
struct NextLevelView: View {
@State private var isLogin: Bool = false
var body: some View {
if !isLogin {
Button("Login") {
isLogin.toggle()
}
} else {
NavigationStack {
NavigationLink("Next Level") {
Button("Logout") {
isLogin.toggle()
}
.navigationTitle("Next Level")
}
// When isLogin is set to false, onAppear is still called once
.onAppear { print("Link onAppear") }
.navigationTitle("Root")
}
// onAppear outside of NavigationStack does not exhibit abnormal calls
.onAppear { print("NavigationStack onAppear") }
}
}
}
As shown in the video: When NavigationStack
is within a conditional branch, after the user navigates to a new page and then changes the branch state (switching to a branch that does not include NavigationStack
), all onAppear
calls in the root view of the NavigationStack
closure (if there are multiple) are abnormally triggered (the final Link onAppear
should not appear).
In Issue 2, NavigationStack
is also within a conditional branch:
struct TabViewTest: View {
@State private var isLogin: Bool = false
var body: some View {
if !isLogin {
Button("Login") {
isLogin.toggle()
}
} else {
TabView {
NavigationStack {
Text("AA")
Button("Logout") {
isLogin.toggle()
}
.onAppear { print("onAppear: start 111") }
.navigationTitle("AA")
}
.onAppear { print("onAppear: NavigationStack 111") }
.tabItem { Text("AA") }
NavigationStack {
Text("BB")
Button("Logout") {
isLogin.toggle()
}
.onAppear { print("onAppear: start 222") }
.navigationTitle("BB")
}
.onAppear { print("onAppear: NavigationStack 222") }
.tabItem { Text("AA") }
}
}
}
}
When we perform the following sequence of actions: click Login
-> click Tab BB
-> click Logout
, we can see that when switching to a branch that does not include NavigationStack
, all onAppear
calls in the Tab AA
view are abnormally triggered, regardless of whether they are inside or outside the navigation container.
Is This a Bug?
Based on previous experience analyzing the abnormal onChange
calls, I first ran the above two code snippets on the macOS platform. The results showed that everything was normal, and there were no abnormal calls. This indicates that it is indeed a bug.
Additionally, I conducted further verification on iOS. By replacing NavigationStack
with NavigationView
and replacing onAppear
with task
, the issue still exhibited remarkable consistency. From current testing, this anomaly can be traced back to at least iOS 15.
Is There a Pattern?
The following patterns can currently be summarized:
- The navigation container must be within a conditional branch.
- The navigation container needs to perform certain operations (such as entering a new navigation page or creating multiple navigation container instances).
- The abnormal calls occur when switching to a branch that does not include the navigation container.
How Significant is This Bug?
If you only adjust the local state of the current view within onAppear
, even though there will be repeated calls, it usually does not cause substantial impacts (there may be slight performance overhead).
However, if you modify a higher-level view or global state within onAppear
, it may lead to unpredictable changes in the application’s state. For example:
.onAppear {
glableState.toggle() // Causes global state changes even after switching to a branch that does not include NavigationStack
}
Solutions
The timing of onAppear
calls is closely related to the type of container it resides in. For example, in a TabView
, every time a tab is switched to the foreground, the onAppear
within it will be called. In some containers, onAppear
is only called once during the container’s lifecycle.
Since developers perform different logical operations in onAppear
based on the container being used, there is no one-size-fits-all solution.
For Issue 1, we can create an onAppear
that is only called once during the container’s lifecycle to avoid multiple calls:
struct OnceAppearModifier: ViewModifier {
@State private var called = false
private let action: (() -> Void)?
init(perform action: (() -> Void)? = nil) {
self.action = action
}
func body(content: Content) -> some View {
content
.onAppear {
guard !called, let action else { return }
action()
called = true
}
}
}
extension View {
public func onceAppear(perform action: (() -> Void)? = nil) -> some View {
modifier(OnceAppearModifier(perform: action))
}
}
After replacing the onAppear
on NavigationLink
with onceAppear
, the logic closure will not be called repeatedly.
For Issue 2, to retain the onAppear
call behavior each time a Tab
is switched, we can create a version of onAppear
with a binding value to ensure that the logic closure is only executed under specific conditions:
extension View {
public func onAppear(enable: Binding<Bool>, perform action: (() -> Void)? = nil) -> some View {
return onAppear {
if enable.wrappedValue {
action?()
}
}
}
}
Replace the problematic onAppear
with .onAppear(enable: $isLogin)
to resolve the issue.
The reason for using the
Binding
type is that we need to directly access the underlying value of the state when the view refreshes. For more details on this mechanism, refer to the article Cracking the Code: The Mysterious @State Injection Mechanism.
Resignation and Outlook
Every time I write such technical troubleshooting articles, I can’t help but feel a bit of helplessness. Despite SwiftUI’s development to date, it still struggles to guarantee consistent behavior in many scenarios, which is indeed lamentable. According to the latest usage statistics, 23% of binary files in iOS 18 already contain SwiftUI code. Perhaps only when a large number of Apple’s own applications encounter such SwiftUI anomalies can these issues be fundamentally resolved.
I have submitted a feedback report (FB16117635) to Apple regarding this issue.