As we all know, SwiftUI is a reactive framework, which means that the framework automatically updates the view when the data source changes. Similarly, when we want to adjust the view display, we should directly modify the state. However, some system controls in SwiftUI need to fully follow the principles of reactive design, which can lead to serious issues in certain situations, affecting the user experience and putting developers at a loss.
This article will explore two serious issues in SwiftUI caused by the failure to implement reactive programming principles and provide corresponding solutions. These issues are:
- after canceling the Sheet by gesture, quickly swiping right on the navigation container causes the application to lock up
- returning to the upper-level view while scrolling causes the application to crash
View changes come first; state changes come after.
In SwiftUI, certain programmable controls will first update the view when performing certain operations and then modify the corresponding state after the view change is complete. These controls are secondary wrappers for UIkit (AppKit).
Sheet
By executing the following code, you can see that when dismissing a Sheet through gestures, the associated state only changes after the dismiss animation is completed. However, when calling the environment value or directly modifying the binding state, SwiftUI follows the principle of reactive programming and first adjusts the state before updating the view.
struct SheetDemo: View {
@StateObject var store = SheetStore()
var body: some View {
Button("Show") {
store.show.toggle()
}
.sheet(isPresented: $store.show) {
SheetView()
.environmentObject(store)
}
}
}
struct SheetView: View {
@Environment(.dismiss) var dismiss
@EnvironmentObject var store: SheetStore
var body: some View {
VStack {
Button("Dismiss by ENV") {
print("Dismiss by ENV")
dismiss()
}
Button("Dismiss by Store") {
print("Dismiss by Store")
store.show = false
}
}
}
}
class SheetStore: ObservableObject {
@Published var show = false {
didSet {
print("show (show ? "T" : "F")")
}
}
}
Please pay attention to the output of the command line interface after performing the operation.
NavigationStack
The NavigationStack also has a similar situation. Run the code below and click the back button in the upper left corner. The path bound to NavigationStack will not change until the view returns to the previous layer. Similarly, returning to the previous layer view through the environment value also requires waiting for the view to return before modifying the state. Only by directly modifying the path can SwiftUI behave like a true reactive programming framework.
struct NavigationStackDemo: View {
@StateObject var store = StackStore()
var body: some View {
NavigationStack(path: $store.path) {
List(0 ..< 20) { i in
NavigationLink(value: i) { Text("(i)") }
}
.navigationDestination(for: Int.self) { n in
Row(n: n)
.environmentObject(store)
}
}
}
}
struct Row: View {
@Environment(.dismiss) var dismiss
@EnvironmentObject var store: StackStore
let n: Int
var body: some View {
List {
Button("Dismiss By ENV") {
print("Dismiss By Env")
dismiss()
}
Button("Dismiss By Store") {
print("Dismiss by Store")
store.path.removeLast()
}
}
.navigationTitle("(n)")
}
}
class StackStore: ObservableObject {
@Published var path = [Int]() {
didSet {
print("set path (path)")
}
}
}
Is there any problem with this?
If we only consider the two examples above, there will be no erroneous results, regardless of whether the state adjustment is timely. However, when the application is in certain special states or when users perform certain specific operations, the delay in state updating can lead to unacceptable consequences.
After dismissing the Sheet with a gesture, quickly swiping right on the navigation container will cause the application to freeze.
This issue exists in all versions of SwiftUI, and you can see many developers looking for solutions on various forums or chat rooms. The reproduction condition is very simple:
- Test on a physical device (difficult to reproduce on a simulator)
- Tap the “GO” button to enter the next level view
- Tap the “Show Sheet” button to display a sheet
- Swipe down to dismiss the sheet
- Immediately after the sheet is dismissed (when the animation ends), swipe from left to right on the screen to return to the previous level view
- After swiping back to the previous level view, the application will freeze.
struct SheetDismissDemo: View {
@State var showSheet = false
var body: some View {
NavigationStack {
VStack {
NavigationLink("GO") {
VStack {
Button("Show Sheet") {
showSheet.toggle()
}
.sheet(isPresented: $showSheet) {
SheetDetailView()
}
}
}
}
}
}
}
struct SheetDetailView: View {
var body: some View {
Text("Sheet")
}
}
Please observe that, after attempting to use a gesture to return to the previous view, the “Back” button in the upper left corner disappears, but the view does not actually return to the root view.
If I tell you that the above situation is caused by the lag in state updates mentioned earlier, how would you avoid this problem?
First, let’s do the following test:
struct SheetDetailView: View {
@Binding var isPresented: Bool
var body: some View {
Button("Dismiss") {
isPresented = false
}
}
}
After modifying the code of SheetDetailView, we no longer use gestures to dismiss the sheet. Instead, we implement this operation by clicking the “Dismiss” button. If you repeat the above process, you will find that after returning to the upper-level view, the application will not lock up, and everything will return to normal.
However, forcing users to click the “Dismiss” button is not a good choice, especially when gestures to dismiss the sheet are not blocked.
With the following code, we can allow users to use the swipe-down gesture to dismiss the sheet without causing the application to lock up.
struct SheetDismissDemo: View {
@State var showSheet = false
var body: some View {
NavigationStack {
VStack {
NavigationLink("GO") {
VStack {
Button("Show Sheet") {
showSheet.toggle()
}
.sheet(isPresented: $showSheet) {
SheetDetailView()
}
}
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.overlay(
Group {
// disable NavigationStack gesture when showSheet is true
if showSheet {
Color.white.opacity(0.01)
.highPriorityGesture(DragGesture(minimumDistance: 0))
}
}
)
}
}
struct SheetDetailView: View {
var body: some View {
Text("Sheet")
}
}
The principle is as follows: when showSheet is true, add an overlay view that blocks gestures to the NavigationStack to ensure that the user can only slide back to the previous view when showSheet is false.
Returning to the previous view while the view is scrolling will cause the application to crash.
This is a problem raised by xiaogd in my Discord forum here. The reproduction conditions are as follows:
- Test on a physical device or simulator with the OS 16 system.
- Click the button in the view list to enter the next level view. Please enter at least the third-level view.
- Scroll the current view.
- When the view is in a scrolling state, click the “Back” button on the top left of NavigationStack.
- After returning to the upper-level view, continue to click the “Back” button.
- The application is likely to crash.
struct NavigationStackBackDemo: View {
@StateObject var pathHolder = PathHolder()
var body: some View {
NavigationStack(path: $pathHolder.path) {
DetailView()
.navigationDestination(for: Int.self) { _ in
DetailView()
}
}
.environmentObject(pathHolder)
}
}
struct DetailView: View {
@EnvironmentObject var holder: PathHolder
var body: some View {
ScrollView {
ForEach(0 ..< 100) { i in
NavigationLink(value: i) {
Text("(i)")
.font(.title)
.foregroundStyle(.yellow)
.frame(maxWidth: .infinity)
.frame(height:150).padding(.vertical,5)
.background(.blue)
}
}
}
.navigationBarTitleDisplayMode(.inline)
.navigationTitle(!holder.path.isEmpty ? "(holder.path.count)" : "Root")
}
}
class PathHolder: ObservableObject {
@Published var path = [Int](){
didSet{
print("set path (path)")
}
}
}
Based on the previous description, we know that the state will only be updated when the view returns to the previous layer after directly clicking the Back button provided by NavigationStack. If the problem lies here, we must use programmatic navigation to adjust the code.
To avoid affecting the user’s habits, we have disabled the Back button provided by NavigationStack. By customizing the back button and extending the UINavigationController, we have achieved support for gesture return after disabling the Back button and modifying the state before responding to the view.
ScrollView {
....
}
// start
.navigationBarBackButtonHidden(true)
.toolbar {
if !holder.path.isEmpty {
ToolbarItem(placement: .topBarLeading) {
Button {
holder.path.removeLast()
} label: {
Image(systemName: "chevron.backward")
}
}
}
}
// end
.navigationBarTitleDisplayMode(.inline)
Code to extend UINavigationController
extension UINavigationController: UIGestureRecognizerDelegate {
override open func viewDidLoad() {
super.viewDidLoad()
interactivePopGestureRecognizer?.delegate = self
}
// Allows swipe back gesture after hiding standard navigation bar with .navigationBarHidden(true).
public func gestureRecognizerShouldBegin(_: UIGestureRecognizer) -> Bool {
viewControllers.count > 1
}
// Allows interactivePopGestureRecognizer to work simultaneously with other gestures.
public func gestureRecognizer(_: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith _: UIGestureRecognizer) -> Bool {
viewControllers.count > 1
}
// Blocks other gestures when interactivePopGestureRecognizer begins (my TabView scrolled together with screen swiping back)
public func gestureRecognizer(_: UIGestureRecognizer, shouldBeRequiredToFailBy _: UIGestureRecognizer) -> Bool {
viewControllers.count > 1
}
}
This issue has been fixed in iOS 17. I’m not sure if it’s related to the feedback we submitted to Apple after discussing it on Discord.
Why does lagging state updates lead to serious errors?
Due to SwiftUI’s opacity, it is not easy to analyze the causes of these issues. Fortunately, I found some clues from @KyleSwifter’s article Demystify AttributeGraph behind SwiftUI.
AttributeGraph is a tool SwiftUI uses to maintain the dependency relationships between multiple data sources and views. To improve the efficiency of AttributeGraph and reduce its space usage, SwiftUI will clean and maintain it in certain situations (such as monitoring the runtime’s idle time through CFRunLoopObserverCreate).
In both scenarios where we encountered issues, the application used a navigation container and put the RunLoop in a state suitable for AG packaging updates through a specific operation. Since the state has not been updated when returning to the upper-level view, cleaning AG (while the return animation is running) will destroy the AttributeGraph integrity of the application, leading to application deadlocks or crashes.
Therefore, by updating the state first and having SwiftUI respond to the state’s changes (returning to the upper-level view), even if AG is cleaned now, the AttributeGraph integrity can still be ensured, and the application will not encounter any problems.
The issue of delayed status updates is not limited to the two cases introduced in this article. When developers encounter similar situations, they can try to use a development strategy that prioritizes status updates for modifications.
Conclusion
This year marks the fifth year of SwiftUI. With each new version, SwiftUI’s features have indeed been significantly increased. However, even in the latest version, some details are still not handled properly in some controls that wrap UIKit (AppKit). We hope the SwiftUI development team can pay attention to these issues as soon as possible.
Want to Connect?
@fatbobman on Twitter.
Common Pitfalls Caused by Delayed State Updates in SwiftUI was originally published in Better Programming on Medium, where people are continuing the conversation by highlighting and responding to this story.