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