As NavigationLink(destination:isActive:label:) has been deprecated, I've been trying to migrate our code to NavigationStack(path:root:).
However, I've been having a few questions/issues with NavigationStack.
NavigationStack not working well inside a TabView
Having a NavigationStack as the root of a TabView tab is pretty common, even in Apple's default apps. But if a NavigationStack that is not the first tab has a not empty path the first time it's is displayed, you get the following message in the Xcode console.
Do not put a navigation destination modifier inside a "lazy” container, like List or LazyVStack. These containers create child views only when needed to render on screen. Add the navigation destination modifier outside these containers so that the navigation stack can always see the destination. There's a misplaced navigationDestination(for:destination:) modifier for type Destination. It will be ignored in a future release.
The example code below triggers this message when run. You don't even have to do any action for the message to appear.
import SwiftUI
struct ContentView: View {
enum Tab { case one, two }
enum Destination { case foo }
@State var tab: Tab = .two
@State var path: [Destination] = [.foo]
var body: some View {
TabView(selection: $tab) {
Color.red
.tabItem {
Label("Tab 1", systemImage: "tray.and.arrow.down.fill")
}
.tag(Tab.one)
NavigationStack(path: $path) {
Color.blue
.navigationDestination(for: Destination.self) { _ in
Color.green
.navigationBarTitleDisplayMode(.inline)
}
}
.tabItem {
Label("Tab 2", systemImage: "tray.and.arrow.up.fill")
}
.tag(Tab.two)
}
}
}
The warning is incorrect. I'm not using any List or LazyVStack, and where .navigationDestination() is called does not seem incorrect to me. I can understand that the tabs in a TabView are created lazily, but what am I supposed to do instead?
The warning is also triggered if path starts empty but is modified before its tab is displayed. Am I not supposed to modified the path of a NavigationStack that is in a tab not currently displayed? In our app we have a button that changes what's in a different tab before switching to it. Is it a supported use case?
(Trying with the latest Xcode 16 beta, no warning appears in the console, but I'm not sure if it's because something was fixed, or just that the current betas don't display such warnings.)
Shouldn't navigationDestination() have an implicit .id(destination)?
In our app, we have a button that replaces what's displayed in a different tab before switching to it. And reimplementing it with NavigationStack(path:root:), I had multiple cases where the content on that different tab did not update properly. With some investigation, it seems it's because SwiftUI did not consider the new content different from the previous one. I fixed that by adding .id(destination) at the end of the closure passed to .navigationDestination(), but it seemed to me that that .id(destination) should have been implicit (like it is for for example ForEach elements).
For example, when running the example below, pressing the "Change" button changes the path but nothing visually changes. You replaced a instance of Foo with another instance of Foo so SwiftUI thinks it's the same and does not reset the state. Of course, if i was an instance variable, the content would be updated, but having a @State/@StateObject initialized that way does not seem that uncommon when fetching data from the network. As I explained above, enclosing switch destination { ... } inside a Group { ... }.id(destination) fixes the problem but having to do it explicitly seemed unnatural to me, and might end up in a behavior the developer does not expect.
import SwiftUI
struct ContentView: View {
enum Tab { case one, two }
enum Destination: Hashable {
case foo(Int)
}
@State var tab: Tab = .one
@State var path: [Destination] = [.foo(1)]
var body: some View {
NavigationStack(path: $path) {
Color.blue
.navigationDestination(for: Destination.self) { destination in
switch destination {
case .foo(let i):
Foo(i: i, path: $path)
}
}
}
}
}
struct Foo: View {
@State var i: Int
@Binding var path: [ContentView.Destination]
var body: some View {
ZStack {
Color.black
VStack {
Button("Change") {
path = [.foo(i + 1)]
}
Text(String(i))
.bold()
.foregroundStyle(Color.white)
}
}
.navigationBarTitleDisplayMode(.inline)
}
}
Did I misunderstood something?
No equivalent of dismiss() for pushing
To pop the latest element of the path (or close a modal), you can use @Environment(\.dismiss), but I could not find something similar for pushing to the active NavigationStack. You are maybe supposed to use NavigationLink(value:label:) but it won't let you do logging when triggered, you cannot use an existing ButtonStyle (and there's no NavigationLinkStyle), and you cannot activate it programmatically. So either you pass a Binding of your path to each child view that might need it, or you have to roll you own mechanism using @Environment (or @EnvironmentObject).
Is there any existing mechanism I'm missing?
Progress
In fact I have seen other problems with NavigationStack. For example, if you change NavigationPath's path during the animation of a previous change to it, the new change does not get reflected on screen. But trying on the latest Xcode beta, it seems it has have been fixed in iOS 18. So Apple is definitely improving NavigationStack. But as long as we have to support iOS 17 and below, we have to be careful and make sure we continue to test well on iOS 16-17.
Post
Replies
Boosts
Views
Activity
Hi,
I'd like to mark views that are inside a LazyVStack as headers for VoiceOver (make them appear in the headings rotor).
In a VStack, you just have add .accessibilityAddTraits(.isHeader) to your header view. However, if your view is in a LazyVStack, that won't work if the view is not visible. As its name implies, LazyVStack is lazy so that makes sense.
There is very little information online about system rotors, but it seems you are supposed to use .accessibilityRotor() with the headings system rotor (.accessibilityRotor(.headings)) outside of the LazyVStack. Something like the following.
.accessibilityRotor(.headings) {
ForEach(entries) { entry in
// entry.id must be the same as the id of the SwiftUI view it is about
AccessibilityRotorEntry(entry.name, id: entry.id)
}
}
It kinds of work, but only kind of. When using .accessibilityAddTraits(.isHeader) in a VStack, the view is in the headings rotor as soon as you change screen. However, when using .accessibilityRotor(.headings), the headers (headings?) are not in the headings rotor at the time the screen appears. You have to move the accessibility focus inside the screen before your headers show up.
I'm a beginner in regards to VoiceOver, so I don't know how a blind user used to VoiceOver would perceive this, but it feels to me that having to move the focus before the headers are in the headings rotor would mean some users would miss them.
So my question is: is there a way to have headers inside a LazyVStack (and are not necessarily visible at first) to be in the headings rotor as soon as the screen appears? (be it using .accessibilityRotor(.headings) or anything else)
The "SwiftUI Accessibility: Beyond the basics" talk from WWDC 2021 mentions custom rotors, not system rotors, but that should be close enough. It mentions that for accessibilityRotor to work properly it has to be applied on an accessibility container, so just in case I tried to move my .accessibilityRotor(.headings) to multiple places, with and without the accessibilityElement(children: .contain) modifier, but that did not seem to change the behavior (and I could not understand why accessibilityRotor could not automatically make the view it is applied on an accessibility container if needed).
Also, a related question: when using .accessibilityRotor(.headings) on a screen, is it fine to mix uses of .accessibilityRotor(.headings) and .accessibilityRotor(.headings)? In a screen with multiple type of contents (something like ScrollView { VStack { MyHeader(); LazyVStack { /* some content */ }; LazyVStack { /* something else */ } } }), having to declare all headers in one place would make code reusability harder.
Thanks