I'm trying to get a deep nested programmatic navigation stack in order. The following code works as expected when navigation is done by hand (ie: pressing the links). When you press the Set Nav button the navigation stack does change - but not as expected - and you end up with a broken stack [start -> b -> bbb] with much flipping between views
class NavState: ObservableObject {
@Published var firstLevel: String? = nil
@Published var secondLevel: String? = nil
@Published var thirdLevel: String? = nil
}
struct LandingPageView: View {
@ObservedObject var navigationState: NavState
func resetNav() {
self.navigationState.firstLevel = "b"
self.navigationState.secondLevel = "ba"
self.navigationState.thirdLevel = "bbb"
}
var body: some View {
return NavigationView {
List {
NavigationLink(
destination: Place(
text: "a",
childValues: [ ("aa", [ "aaa"]) ],
navigationState: self.navigationState
).navigationBarTitle("a"),
tag: "a",
selection: self.$navigationState.firstLevel
) {
Text("a")
}
NavigationLink(
destination: Place(
text: "b",
childValues: [ ("bb", [ "bbb"]), ("ba", [ "baa", "bbb" ]) ],
navigationState: self.navigationState
).navigationBarTitle("b"),
tag: "b",
selection: self.$navigationState.firstLevel
) {
Text("b")
}
Button(action: self.resetNav) {
Text("Set Nav")
}
}
.navigationBarTitle("Start")
}
.navigationViewStyle(StackNavigationViewStyle())
}
}
struct Place: View {
var text: String
var childValues: [ (String, [String]) ]
@ObservedObject var navigationState: NavState
var body: some View {
List(childValues, id: \.self.0) { childValue in
NavigationLink(
destination: NextPlace(
text: childValue.0,
childValues: childValue.1,
navigationState: self.navigationState
).navigationBarTitle(childValue.0),
tag: childValue.0,
selection: self.$navigationState.secondLevel
) {
Text(childValue.0)
}
}
}
}
struct NextPlace: View {
var text: String
var childValues: [String]
@ObservedObject var navigationState: NavState
var body: some View {
List(childValues, id: \.self) { childValue in
NavigationLink(
destination: FinalPlace(
text: childValue,
navigationState: self.navigationState
).navigationBarTitle(childValue),
tag: childValue,
selection: self.$navigationState.thirdLevel
) {
Text(childValue)
}
}
}
}
struct FinalPlace: View {
var text: String
@ObservedObject var navigationState: NavState
var body: some View {
let concat: String = "\(navigationState.firstLevel)/\(navigationState.secondLevel))/\(navigationState.thirdLevel)/"
return VStack {
Text(text)
Text(concat)
}
}
}
I originally attempted to tackle navigation transition animations as a problem source - but How to disable NavigationView push and pop animations is suggesting that this is not configurable
Are there any sane examples of >1 level programmatic navigation working out there?
Edit: Part of what I am looking to get here is also initial state for navigation working correctly - if I come in from an external context with a navigation state I wish to reflect (ie: from a notification with some in-app context to start from, or from a saved-to-disk-encoded-state) then I would expect to be able to load up the top View with navigation correctly pointing to the right child view. Essentially - replace the nils in the NavState with real values. Qt's QML and ReactRouter can both do this declaratively - SwiftUI should be able to as well.
Now we have NavigationStack with dynamic heterogenous path support. So updated approach could be as follows.
Note: although original could be simplified now a lot: a) I wanted preserve view hierarchy b) I wanted to show handling of different model types
Tested with Xcode 14 / iOS 16

Test on GitHub
struct LandingPageView2: View {
class NavState: ObservableObject {
@Published var path = NavigationPath()
let level1 = [
"a" : ["bb", "ba"],
"b" : ["bb", "ba"]
]
let level2 = [
"bb" : ["baa", "bbb"],
"ba" : ["baa", "bbb"]
]
}
struct Lev1: Hashable {
var text: String
}
struct Lev2: Hashable {
var text: String
}
struct Lev3: Hashable {
var text: String
}
func resetNav() {
self.navigationState.path.append(Lev1(text: "b"))
self.navigationState.path.append(Lev2(text: "ba"))
self.navigationState.path.append(Lev3(text: "bbb"))
}
@ObservedObject var navigationState: NavState
var body: some View {
NavigationStack(path: $navigationState.path) {
List {
NavigationLink("a", value: Lev1(text: "a"))
NavigationLink("b", value: Lev1(text: "b"))
Button(action: self.resetNav) {
Text("Set Nav")
}
}
.navigationDestination(for: Lev1.self) {
Place(text: $0.text, childValues: navigationState.level1[$0.text] ?? [])
}
.navigationDestination(for: Lev2.self) {
NextPlace(text: $0.text, childValues: navigationState.level2[$0.text] ?? [])
}
.navigationDestination(for: Lev3.self) {
FinalPlace(text: $0.text)
}
.navigationBarTitle("Start")
}
.navigationViewStyle(StackNavigationViewStyle())
.environmentObject(navigationState)
}
// MARK: -
struct Place: View {
var text: String
var childValues: [String]
var body: some View {
List(childValues, id: \.self) {
NavigationLink($0, value: Lev2(text: $0))
}
.navigationTitle(text)
}
}
struct NextPlace: View {
var text: String
var childValues: [String]
var body: some View {
List(childValues, id: \.self) {
NavigationLink($0, value: Lev3(text: $0))
}
.navigationTitle(text)
}
}
struct FinalPlace: View {
var text: String
@EnvironmentObject var navigationState: NavState
var body: some View {
VStack {
Text(text)
}
}
}
}
This is because new stack level is formed when animation is completed, and that's why it works in the case of manual tap.
With the following modification it works:
func resetNav() {
self.navigationState.firstLevel = "b"
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
self.navigationState.secondLevel = "ba"
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
self.navigationState.thirdLevel = "bbb"
}
}
}
Putting my own solution up here as a option in the case that someone is hitting the same requirements for navigation that NavigationView does not seem to be able fulfill. In my book - functionality is a lot harder to fix than graphical elements and animation. Inspired by https://medium.com/swlh/swiftui-custom-navigation-view-for-your-applications-7f6effa7dbcf
It works off the idea of a single declarative variable at the root level determines the navigation stack - binding to individual variables seems odd when an operation like jumping from the a chain to the b chain in my above demo or starting at a particular position is required
I've used the concept of a stringified path like a URI as the variable concept - this could probably be replaced with a more expressive model (like a vector of enums)
Big ol caveats - its very rough, has no animations, doesn't look native at all, uses AnyView, you can't have the same node name more than once, only reflects a StackNavigationViewStyle, etc - if I make this into something more pretty, sane and generic I'll put that up in Gist.
struct NavigationElement {
var tag: String
var title: String
var viewBuilder: () -> AnyView
}
class NavigationState: ObservableObject {
let objectWillChange = ObservableObjectPublisher()
var stackPath: [String] {
willSet {
if rectifyRootElement(path: newValue) {
_stackPath = newValue
rectifyStack(path: newValue, elements: _stackElements)
}
}
}
/// Temporary storage for the most current stack path during transition periods
private var _stackPath: [String] = []
@Published var stack: [NavigationElement] = []
var stackElements: [String : NavigationElement] = [:] {
willSet {
_stackElements = newValue
rectifyStack(path: _stackPath, elements: newValue)
}
}
/// Temporary storage for the most current stack elements during transition periods
private var _stackElements: [String : NavigationElement] = [:]
init(initialPath: [String] = []) {
self.stackPath = initialPath
rectifyRootElement(path: initialPath)
_stackPath = self.stackPath
}
@discardableResult func rectifyRootElement(path: [String]) -> Bool {
// Rectify root state if set from outside
if path.first != "" {
stackPath = [ "" ] + path
return false
}
return true
}
private func rectifyStack(path: [String], elements: [String : NavigationElement]) {
var newStack: [NavigationElement] = []
for tag in path {
if let elem = elements[tag] {
newStack.append(elem)
}
else {
print("Path has a tag '\(tag)' which cannot be represented - truncating at last usable element")
break
}
}
stack = newStack
}
}
struct NavigationStack<Content: View>: View {
@ObservedObject var navState: NavigationState
@State private var trigger: String = "humperdoo" //HUMPERDOO! Chose something that would not conflict with empty root state - could probably do better with an enum
init(_ navState: NavigationState, title: String, builder: @escaping () -> Content) {
self.navState = navState
self.navState.stackElements[""] = NavigationElement(tag: "", title: title, viewBuilder: { AnyView(builder()) })
}
var backButton: some View {
Button(action: { self.navState.stackPath.removeLast() }) {
Text("Back")
}
}
var navigationHeader: some View {
HStack {
ViewBuilder.buildIf( navState.stack.count > 1 ? backButton : nil )
Spacer()
ViewBuilder.buildIf( navState.stack.last?.title != nil ? Text(navState.stack.last!.title) : nil )
Spacer()
}
.frame(height: 40)
.background(Color(.systemGray))
}
var currentNavElement: some View {
return navState.stack.last!.viewBuilder()
}
var body: some View {
VStack {
// This is an effectively invisible element which primarily serves to force refreshes of the tree
Text(trigger)
.frame(width: 0, height: 0)
.onReceive(navState.$stack, perform: { stack in
self.trigger = stack.reduce("") { tag, elem in
return tag + "/" + elem.tag
}
})
navigationHeader
ViewBuilder.buildBlock(
navState.stack.last != nil
? ViewBuilder.buildEither(first: currentNavElement)
: ViewBuilder.buildEither(second: Text("The navigation stack is empty - this is odd"))
)
}
}
}
struct NavigationDestination<Label: View, Destination: View>: View {
@ObservedObject var navState: NavigationState
var tag: String
var label: () -> Label
init(_ navState: NavigationState, tag: String, title: String, destination: @escaping () -> Destination, label: @escaping () -> Label) {
self.navState = navState
self.tag = tag
self.label = label
self.navState.stackElements[tag] = NavigationElement(tag: tag, title: title, viewBuilder: { AnyView(destination()) })
}
var body: some View {
label()
.onTapGesture {
self.navState.stackPath.append(self.tag)
}
}
}
And some basic usage code
struct LandingPageView: View {
@ObservedObject var navState: NavigationState
var destinationA: some View {
List {
NavigationDestination(self.navState, tag: "aa", title: "AA", destination: { Text("AA") }) {
Text("Go to AA")
}
NavigationDestination(self.navState, tag: "ab", title: "AB", destination: { Text("AB") }) {
Text("Go to AB")
}
}
}
var destinationB: some View {
List {
NavigationDestination(self.navState, tag: "ba", title: "BA", destination: { Text("BA") }) {
Text("Go to BA")
}
Button(action: { self.navState.stackPath = [ "a", "ab" ] }) {
Text("Jump to AB")
}
}
}
var body: some View {
NavigationStack(navState, title: "Start") {
List {
NavigationDestination(self.navState, tag: "a", title: "A", destination: { self.destinationA }) {
Text("Go to A")
}
NavigationDestination(self.navState, tag: "b", title: "B", destination: { self.destinationB }) {
Text("Go to B")
}
}
}
}
}
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With