Common Pitfalls Caused by Delayed State Updates in SwiftUI

Published on

Get weekly handpicked updates on Swift and SwiftUI!

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 do not fully follow the principles of reactive design, which can lead to serious issue in certain situations, affecting the user experience and making 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. Include: after canceling the Sheet by gesture, quickly swiping right on the navigation container causes the application to lock up; and 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 update the view first when performing certain operations, and will then modify the corresponding state after the view change is complete. These controls are basically secondary wrappers for UIkit (AppKit).

Sheet

By executing the following code, you can clearly 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.

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

https://cdn.fatbobman.com/sheet-dismiss-demo_2023-08-29_15.37.17.2023-08-29%2015_40_10.gif

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.

Swift
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)")
        }
    }
}

https://cdn.fatbobman.com/stack-back-demo_2023-08-29_15.55.31.2023-08-29%2015_56_48.gif

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 or not. 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 is an issue that 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 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.
Swift
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.

https://cdn.fatbobman.com/sheet-dismiss-demo2_Final1693298235.2023-08-29%2016_39_51.gif

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 a test:

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

Swift
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 a 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 physical device or simulator with 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 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.
Swift
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)")
        }
    }
}

https://cdn.fatbobman.com/navigationStack-back-demo2_2023-08-29_18.10.50.2023-08-29%2018_12_07.gif

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 we think the problem lies here, we need to 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 first modified the state before responding to the view.

Swift
ScrollView {
  ....
}
// start
.navigationBarBackButtonHidden(true)
.toolbar {
    if !holder.path.isEmpty {
        ToolbarItem(placement: .topBarLeading) {
            Button {
                holder.path.removeLast()
            } label: {
                Image(systemName: "chevron.backward")
            }
        }
    }
}
// end
.navigationBarTitleDisplayMode(.inline)

Extend UINavigationController:

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

https://cdn.fatbobman.com/navigationStack-back-demo3_2023-08-29_18.20.16.2023-08-29%2018_21_23.gif

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 used by SwiftUI to maintain the dependency relationships between multiple data sources and views. In order 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 of the scenarios where we encountered issues, the application happened to use 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 then having SwiftUI respond to the state’s changes (returning to the upper-level view), even if AG is cleaned at this time, 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, there are still some details that are not handled properly in some controls that wrap UIKit (AppKit). We hope that the SwiftUI development team can pay attention to these issues as soon as possible.

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