Recursive NavigationStack? → stuck at level 2?

Folks,

I’m trying to navigate through a tree using … well, some SwiftUI widget. I've tried OutlineGroup and it works, but the big drawback is that you can’t lazily download the tree, since there is no state variable of sorts that informs you when a disclosure sign > is pressed. So you have to download the whole tree at once, including items that you’re likely no to view ever.

I tried the new NavigationStack, with that sort of idea:

NavigationStack {
   RecursiveView (rootItemList)
}

struct RecursiveView (itemList) {
  List {
    ForEach (item in itemList) {
      if item.isLeaf {
        someView (item)
      } else {
         NavigationLink (destination: RecursiveView(childList))
    }
  }
}

Well, it does work up to one level of recursion. Afterwards, it stops working and I get these messages: onChange(of: UpdateTrigger) action tried to update multiple times per frame or/and: Only root-level navigation destinations are effective for a navigation stack with a homogeneous path.

So I suppose this widget is not fit for that kind of navigation. Does anyone have a better idea?

Post not yet marked as solved Up vote post of Vingt-Cent Down vote post of Vingt-Cent
1.5k views

Replies

Hi, I wanted to detect that a NavigationLink was selected, so I wrote a ViewModifier using list($selection:) and navigationDestination(isPresent:).

If you are not using $path in your NavigationStack(path:), you might be able to use this ViewModifier idea for data loading with onChange(of: selection) {}.

I attach below the sample code for a recursive FileListViewer.

You can get this ViewModifier and other sample code from https://github.com/hmuronaka/NavigationDestinationSelectedViewModifier.

I hope this idea will be helpful to you.

import SwiftUI
import NavigationDestinationSelectedViewModifier

struct PlainFileList2<Destination: View>: View {
    let current: URL
    let paths: [URL]

    @ViewBuilder let destination: (URL) -> Destination
    @State private var selection: URL?
    @State private var childPaths: [URL]?

    var body: some View {
        List(selection: $selection) {
            ForEach(paths, id: \.self) { url in
                if FileManager.default.isDirectory(url: url) {
                    NavigationLink(value: url) {
                        Label(url.lastPathComponent, systemImage: "folder")
                    }
                } else {
                    self.destination(url)
                }
            }
        }
        .navigationDestination(selection: $selection, item: $childPaths ) { childPaths in
            if let selection {
                PlainFileList2(current: selection, paths: childPaths, destination: self.destination)
            }
        }
        .onChange(of: selection, perform: { newValue in
            if let newValue, FileManager.default.isDirectory(url: newValue) {
                self.childPaths = try! FileManager.default.contentsOfDirectory(at: newValue, includingPropertiesForKeys: [.parentDirectoryURLKey, .creationDateKey, .fileSizeKey], options: [])
            } else {
                self.childPaths = nil
            }
        })
        .navigationTitle(current.lastPathComponent)
    }
}

ViewModifier

import SwiftUI

fileprivate struct NavigationDestinationViewModifier<SelectionValue: Hashable, Value: Equatable, Destination: View>: ViewModifier {
   
    @Binding var selection: SelectionValue?
    @Binding var item: Value?
    @ViewBuilder let destination: (Value) -> Destination

    func body(content: Content) -> some View {
        content
            .navigationDestination(isPresented: .init(get: {
                item != nil
            }, set: { newValue in
                if !newValue {
                    item = nil
                }
            })) {
                if let selected = item {
                    destination(selected)
                } else {
                    EmptyView()
                }
            }
            .onChange(of: item) { newValue in
                if newValue == nil && selection != nil {
                    selection = nil
                }
            }
    }
}

public extension View {
    func navigationDestination<SelectionValue: Hashable, Value: Equatable, Destination: View>(selection: Binding<SelectionValue?>, item: Binding<Value?>, @ViewBuilder destination: @escaping (Value) -> Destination) -> some View {
        return self.modifier(NavigationDestinationViewModifier(selection: selection, item: item, destination: destination))
    }
}

If you simply want to be able to recurse, the following code might work.

NavigationStack {
   RecursiveView (rootItemList)
     .navigationDestination(for: Item.self) { item in
       RecursiveView(item.childList)
     }
}

struct RecursiveView (itemList) {
  List {
    ForEach (item in itemList) {
      if item.isLeaf {
        someView (item)
      } else {
         NavigationLink(value: item) { label(item) }
    }
  }
}

Thanks a bunch!

I ended up adopting an entirely different tactics (I wrote an OutlineGroup-like widget where I have individual control over the state of the different DisclosureGroups – to be more precise, there isn't even a single DisclosureGroup involved in it, but it looks like if). However, I’ll be storing your code for future use!

Once again, thanks so much for answering!