How to avoid trailing toolbar item re-rendering on child navigation

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

                }
            }
        }
    }
}

Answered by Frameworks Engineer in 892664022

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

                }
            }
        }
    }
}

Hi! Do you have a paired-down sample that shows the bar behavior you're seeing for the page 2->3 transition? That would help with narrowing down what's going on here

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

                }
            }
        }
    }
}
How to avoid trailing toolbar item re-rendering on child navigation
 
 
Q