Search code examples
swiftuiswiftui-navigationstackswiftui-navigationsplitview

Why programmatic navigation of NavigationStack inside a NavigationSplitView doesn't work without a delay?


Here is the simplified version of my app, consisting of NavigationSplitView with NavigationStack as its detail view.

The programmatic navigation I use here works because I delay assignment of the NavigationStack path by a second.

import SwiftUI
import SwiftData

struct ContentView: View {
    @Environment(\.modelContext) private var modelContext
    @Query private var folders: [Folder]

    @State private var currentFolder: Folder?
    @State private var currentPath: [Item] = []

    var body: some View {
        NavigationSplitView {
            List(selection: $currentFolder) {
                ForEach(folders) { folder in
                    NavigationLink(value: folder) {
                        Text(folder.name)
                    }
                }
            }
        } detail: {
            NavigationStack(path: $currentPath) {
                if let currentFolder = currentFolder {
                    List(currentFolder.items) { item in
                        NavigationLink(value: item) {
                            Text(item.timestamp.ISO8601Format())
                        }
                    }
                    .navigationDestination(for: Item.self) { item in
                        Text(item.title)
                            .background(Color.blue)
                    }
                }
            }
        }.onOpenURL(perform: { url in // Triggers by clicking on a widget
            let descriptor = FetchDescriptor<Folder>()
            // Randomly selecting a folder
            // In real app the url contains the folder and the item
            let folder = try? modelContext.fetch(descriptor).last

            if let folder = folder {
                currentFolder = folder // Triggering NavigationSplitView navigation

                DispatchQueue.main.asyncAfter(deadline: .now() + 1) { // After one second
                    currentPath = [folder.items.last!] // Triggering NavigationStack navigation
                }
            }
        })
    }

    private func addFolder() {
    // ..
    }
}

If I won't delay the currentPath assignment, it will only navigate the NavigationSplitView. If the app is in the background and it is already in the correct folder in NavigationSplitView it will navigate to the item in the NavigationStack.

In the apple video (https://developer.apple.com/wwdc22/10054) they do just the assignment without any delay and it works:

// This code work in Apple sample video
currentFolder = folder
currentPath = [folder.items.last!]

Why doesn't it work for me without a delay?


Solution

  • I found a way to make it work consistently, visually attractive and for any platform (iOS, macOS and iPadOS). Here is how to properly assign bindings:

    // ...
    
    var nonAnimatedTransaction: Transaction {
      var t = Transaction()
      t.disablesAnimations = true
    
      return t
    }
    
    // ...
    }.onOpenURL(perform: { url
      // ...
      if let folder = folder {
        withTransaction(nonAnimatedTransaction) {
          currentFolder = folder // Triggers NavigationSplitView without animation
        }
    
        DispatchQueue.main.async {
           currentPath = [folder.items.last!] // Triggers NavigationStack navigation
        }
        // ...
      }
    }
    

    Removing animation for the NavigationSplitView and updating the path inside the main thread solved the issue.

    Having default transition animation (which takes time) prevented the NavigationStack to be ready for the path change and updating the path inside the main thread is the correct way to update binding because the onOpenURL is an async task which runs in a separate thread and all UI related changes should be done in the main thread.

    So this is the way to navigate to the proper path after a click on an app widget. You don't want to see long lasting animations, only the last one.