I have a NavigationStack where every child view except the root has a trailing close button. On each child view transition, the button re-renders itself. Is there a way to fix the button?
I saw in the Apple Developer app the behavior I want when registering a lab and the confirmation screen, but I think the implementation is just changing the center content of the view and it isn't pushing a new view to the stack path that has different toolbar items.
Root: VStack { Content Next button --> Page 2 } Cancel button leading edge
Page 2: VStack { Copy Next button --> page 3 } built in back button leading edge cancel button trailing edge
Page 3: VStack { Copy Finish button --> dismisses whole workflow } built in back button leading edge cancel button trailing edge
So on page 3, the cancel button is new. I can't figure out how to not have the glass effect animate it in new. I want the 'same' cancel button to be there.
This is an oversimplification of a resumable form where the user can cancel (save and resume) at any time.
Is there a built in way to have the trailing edge button be fixed? Would moving where the button is defined make a different and expose a way for each child view to propagate upwards if the cancel button should be shown or not?
Updated with sample, assumes iOS 18 + 26 code so using .topTrailing not new 'action' placement:
import SwiftUI
struct Demo: View {
@State private var path: [String] = []
var body: some View {
NavigationStack(path: $path) {
Button("Go") {
path.append("Second")
}
.navigationDestination(for: String.self) { destination in
switch destination {
case "Second":
VStack {
Text("Second")
Button("Next") {
path.append("Third")
}
}
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Close", role: .cancel) {
path = []
}
}
}
case "Third":
VStack {
Text("Third")
Button("Finish") {
path = []
}
}
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Close", role: .cancel) {
path = []
}
}
}
default:
Text("Undefined")
}
}
}
}
}
My recommendation for a system-standard button would be to use the button role initializer without a text. This also resolves the issue with the transition. Code attached in "A"
If you want to use a custom label, you can specify the same identifier for each button so SwiftUI knows those buttons match and it won't pulse when transitioning. Code attached in "B"
A: With System Standard Button
struct ContentView: View {
@State private var path: [String] = []
var body: some View {
NavigationStack(path: $path) {
Button("Go") {
path.append("Second")
}
.navigationDestination(for: String.self) { destination in
switch destination {
case "Second":
VStack {
Text("Second")
Button("Next") {
path.append("Third")
}
}
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button(role: .cancel) {
path = []
}
}
}
case "Third":
VStack {
Text("Third")
Button("Finish") {
path = []
}
}
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button(role: .cancel) {
path = []
}
}
}
default:
Text("Undefined")
}
}
}
}
}
B: With custom text
struct ContentView: View {
@State private var path: [String] = []
var body: some View {
NavigationStack(path: $path) {
Button("Go") {
path.append("Second")
}
.navigationDestination(for: String.self) { destination in
switch destination {
case "Second":
VStack {
Text("Second")
Button("Next") {
path.append("Third")
}
}
.toolbar(id: "second") {
ToolbarItem(id: "cancel", placement: .topBarTrailing) {
Button("Custom", role: .cancel) {
path = []
}
}
}
case "Third":
VStack {
Text("Third")
Button("Finish") {
path = []
}
}
.toolbar(id: "third") {
ToolbarItem(id: "cancel", placement: .topBarTrailing) {
Button("Custom", role: .cancel) {
path = []
}
}
}
default:
Text("Undefined")
}
}
}
}
}