175

Finally now with Beta 5 we can programmatically pop to a parent View. However, there are several places in my app where a view has a "Save" button that concludes a several step process and returns to the beginning. In UIKit, I use popToRootViewController(), but I have been unable to figure out a way to do the same in SwiftUI.

Below is a simple example of the pattern I'm trying to achieve.

How can I do it?

import SwiftUI

struct DetailViewB: View {
    @Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
    var body: some View {
        VStack {
            Text("This is Detail View B.")

            Button(action: { self.presentationMode.value.dismiss() } )
            { Text("Pop to Detail View A.") }

            Button(action: { /* How to do equivalent to popToRootViewController() here?? */ } )
            { Text("Pop two levels to Master View.") }

        }
    }
}

struct DetailViewA: View {
    @Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
    var body: some View {
        VStack {
            Text("This is Detail View A.")

            NavigationLink(destination: DetailViewB() )
            { Text("Push to Detail View B.") }

            Button(action: { self.presentationMode.value.dismiss() } )
            { Text("Pop one level to Master.") }
        }
    }
}

struct MasterView: View {
    var body: some View {
        VStack {
            Text("This is Master View.")

            NavigationLink(destination: DetailViewA() )
            { Text("Push to Detail View A.") }
        }
    }
}

struct ContentView: View {
    var body: some View {
        NavigationView {
            MasterView()
        }
    }
}
Peter Mortensen
  • 30,738
  • 21
  • 105
  • 131
Chuck H
  • 7,434
  • 4
  • 31
  • 34
  • 2
    I would accept a solution that either pops all the way to the root or pops a specific number of levels greater than one. Thanks. – Chuck H Aug 03 '19 at 15:54
  • Another approach: https://stackoverflow.com/questions/57711277/completely-move-to-other-view-and-dont-allow-to-go-back-in-swiftui/57717462#57717462 – Sajjon Aug 30 '19 at 15:03
  • Take a look at this open source project: https://github.com/biobeats/swiftui-navigation-stack I posted an answer here below about it. – superpuccio Jan 24 '20 at 12:02
  • I stole a better approach for anyone new reading this: https://stackoverflow.com/a/63760934/13293344 – Super Noob Sep 07 '20 at 06:04
  • https://github.com/canopas/UIPilot allows doing it very easily, added an answer below https://stackoverflow.com/a/71259665/2212847 – jimmy0251 Feb 25 '22 at 00:15
  • iOS 16+ has this built in https://stackoverflow.com/questions/76079519/navigationstack-ios16-with-route-how-to-pop-back-to-root/76080377#76080377 – lorem ipsum Jul 23 '23 at 16:50

32 Answers32

245

Setting the view modifier isDetailLink to false on a NavigationLink is the key to getting pop-to-root to work. isDetailLink is true by default and is adaptive to the containing View. On iPad landscape for example, a Split view is separated and isDetailLink ensures the destination view will be shown on the right-hand side. Setting isDetailLink to false consequently means that the destination view will always be pushed onto the navigation stack; thus can always be popped off.

Along with setting isDetailLink to false on NavigationLink, pass the isActive binding to each subsequent destination view. At last when you want to pop to the root view, set the value to false and it will automatically pop everything off:

import SwiftUI

struct ContentView: View {
    @State var isActive : Bool = false

    var body: some View {
        NavigationView {
            NavigationLink(
                destination: ContentView2(rootIsActive: self.$isActive),
                isActive: self.$isActive
            ) {
                Text("Hello, World!")
            }
            .isDetailLink(false)
            .navigationBarTitle("Root")
        }
    }
}

struct ContentView2: View {
    @Binding var rootIsActive : Bool

    var body: some View {
        NavigationLink(destination: ContentView3(shouldPopToRootView: self.$rootIsActive)) {
            Text("Hello, World #2!")
        }
        .isDetailLink(false)
        .navigationBarTitle("Two")
    }
}

struct ContentView3: View {
    @Binding var shouldPopToRootView : Bool

    var body: some View {
        VStack {
            Text("Hello, World #3!")
            Button (action: { self.shouldPopToRootView = false } ){
                Text("Pop to root")
            }
        }.navigationBarTitle("Three")
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

Screen capture

malhal
  • 26,330
  • 7
  • 115
  • 133
  • 14
    This is the best answer and should now be the accepted answer. It does exactly what I want, and it is not a hack. Thanks. – Chuck H Jan 09 '20 at 18:08
  • 16
    For those using custom initializers on your views and having trouble getting them to work, make sure you use Binding on your init parameters "init(rootIsActive: Binding)" , also inside the initializer don't forget to use underscore for local binding var (self._rootIsActive = rootIsActive). When your Previews brake, just use .constant(true) as a parameter. – Repose Feb 03 '20 at 20:06
  • 21
    It works but the naming of "shouldPopToRootView" is not clear. That property effectively disables navigation on the root view. Also, it is better to use environment object to observe the change rather than to pass that binding boolean to every single view in the child. – imthath Feb 08 '20 at 01:18
  • 1
    "Setting the view modifier isDetailLink to false on a NavigationLink is the key to getting pop-to-root to work" I wish I had of seen this first line 3 hours earlier. I wish I could upvote 10 times. – Brett May 20 '20 at 00:33
  • 1
    Any suggestions for making this solution work with watchOS as isDetailList is unavailable in watchOS? – Sid Jun 05 '20 at 03:24
  • 8
    If you have multiple navigation links in the root view, then this solution can get a little tricky. Don't just feed the same boolean binding to the isActive for all of you navigation links (in the root view). Otherwise, when you navigate, all the navigation links will become active at the same time. Tricky. – Scott Marchant Sep 03 '20 at 16:10
  • 8
    Thanks for the inspiration and code. My two critical cents: - the instruction .isDetailLink(false) is unnecessary in ContentView (since it's root view). - the booleans rootIsActive and shouldPopToRootView are very, very badly named. I had tons of difficulty understanding the code because of them. Especially the self.shouldPopToRootView = false thingy looks spookily backwards (false...? really...? we are actually trying to pop to root view, you know... ). What I did was to replace them (together with isActive from ContentView) with one single boolean called stackingPermitted. – Florin Odagiu Sep 06 '20 at 00:04
  • 1
    Contrary to one of @FlorinOdagiu's criticisms, I had to add `.isDetailLink(false)` all the way down to my root view for this to work. So I had to add it to views 0...10 when I wanted to unwind my navigation only from 10 -> 8. This makes sense when considering the Split View that is invisible on iPhone. I do want all my navigations to show up above the parent, and never to the right of it. – AlexMath Sep 28 '20 at 16:35
  • This doesn't seem to work when the NavigationLink in the root view is a navigationBarItem. Is there a way to make it work in that case? – Jon Vinson Oct 25 '20 at 21:38
  • I tried it and the problem is a NavigationLink in a navigationItem or a toolbar ToolbarItem placement navigationBarTrailing does not react to the isActive binding being changed. To test this add .onChange(of: isActive, perform: { i in print("changed isActive \(i)") }) Even worse is if placement is .bottomBar the NavigationLink doesn't work at all. – malhal Oct 26 '20 at 10:48
  • 1
    Here is a possible workaround for using a toolbar button to activate a navigation link (I haven't tested it because the sample code was not self-contained) https://stackoverflow.com/a/63602455/259521 – malhal Oct 26 '20 at 11:00
  • 3
    I'm having a hard time seeing what `self.shouldPopToRootView = false` achieves? It looks like we're just passing false from view 1 to view view 3. – Brosef Jan 02 '21 at 04:28
  • What about if I use FullScreenModalView on root view how can I return to root view without NavigationView ? My root view didn't use NavigationView ! – Basel Jan 04 '21 at 07:38
  • @Basil create a Bool in your View or add a bool to your modal's config struct and declare it with @ State and bind it to the fullScreenCover isPresented https://stackoverflow.com/a/56563652/259521 – malhal Jan 05 '21 at 08:12
  • Really nice solution, I spent 2 more hours on this. Finally the magic key is `isDeatilLink` – Adrian Rusin Jan 11 '21 at 22:08
  • And what if ContentView has more than one NavigationLink? There will be a page switching loop. – Sylar Feb 09 '21 at 07:58
  • There is an additional param for that – malhal Feb 11 '21 at 15:47
  • Is there a way to do this from a UITabBarController that has a UIHostingController as one of its tabs? I've got a method that gets called when a tab button is tapped a second time, and I want to pop to root then. But my UIKit code doesn't hold the @State. I suppose it could. – Rick Aug 07 '21 at 07:57
  • Ah, nevermind, this doesn't really work, as the NavigationLinks are buried across many subviews, and this solution would require touching each one. – Rick Aug 07 '21 at 08:05
  • Adding `.navigationViewStyle(.stack)` will help avoid empty child view on iPad. – Paul B Oct 29 '21 at 19:37
  • I added the solution how to pop to root view without .isDetailLink(false) https://stackoverflow.com/a/70688098/4067700 – Victor Kushnerov Jan 12 '22 at 20:40
  • I have 4 SwiftUI views and from Screen 4 I have two different navigations. 1. Pop to root and 2. pop to screen 2. Can I achieve using the same using this solution? – Nirav Jain Aug 03 '22 at 18:35
  • Hello, I have a case like this. in the ContentView file, it contains a NavigationView which has a Home and Profile page, then the Profile page has an Address List page, then the Address List has 3 more pages, namely the page for adding addresses, namely the Province, City, District election page and the Address Details page. When the User finishes filling in the Address Details, I want to return to the Address List Page, not the Profile. I used the method you described but it didn't work. Can you help me? – Ifadak Sep 12 '22 at 11:07
  • I recommend trying the new `NavigationPath` it gives more control of the nav stack and may be able to do what you need. – malhal Sep 12 '22 at 15:55
  • It doesn't work well with list items in the root view. if the root view contains list and the user tap on the list item, it selects the random position on the list. – Anshul Kabra Feb 07 '23 at 11:13
56

Definitely, malhal has the key to the solution, but for me, it is not practical to pass the Binding's into the View's as parameters. The environment is a much better way as pointed out by Imthath.

Here is another approach that is modeled after Apple's published dismiss() method to pop to the previous View.

Define an extension to the environment:

struct RootPresentationModeKey: EnvironmentKey {
    static let defaultValue: Binding<RootPresentationMode> = .constant(RootPresentationMode())
}

extension EnvironmentValues {
    var rootPresentationMode: Binding<RootPresentationMode> {
        get { return self[RootPresentationModeKey.self] }
        set { self[RootPresentationModeKey.self] = newValue }
    }
}

typealias RootPresentationMode = Bool

extension RootPresentationMode {
    
    public mutating func dismiss() {
        self.toggle()
    }
}

USAGE:

  1. Add .environment(\.rootPresentationMode, self.$isPresented) to the root NavigationView, where isPresented is Bool used to present the first child view.

  2. Either add .navigationViewStyle(StackNavigationViewStyle()) modifier to the root NavigationView, or add .isDetailLink(false) to the NavigationLink for the first child view.

  3. Add @Environment(\.rootPresentationMode) private var rootPresentationMode to any child view from where pop to root should be performed.

  4. Finally, invoking the self.rootPresentationMode.wrappedValue.dismiss() from that child view will pop to the root view.

I have published a complete working example on GitHub.

Peter Mortensen
  • 30,738
  • 21
  • 105
  • 131
Chuck H
  • 7,434
  • 4
  • 31
  • 34
  • 2
    This really helps me. Thank you Chuck and Nikola. – Parth Patel Dec 24 '20 at 19:07
  • 1
    This is indeed an elegant, reusable solution. Took me time to understand how it works, but thanks to your example, I understood. Anyone trying this: try minimizing the example to your needs for better understanding. – brainray Nov 09 '21 at 06:39
  • This is how it's supposed to be done. Using Binding doesn't play nice with DI and this is perfect. – Lukasz D Nov 11 '21 at 13:30
  • 3
    How do you make this work with TabView, and multiple different "root" screens? – Bjørn Olav Jalborg Mar 29 '22 at 12:11
  • It took some time to figure out it's working because one essential piece of information is missing here. When a user taps on a navigation link embedded in a navigation view, the 'isActive' property of this link is automatically set to true. – Awais Fayyaz Jun 30 '22 at 13:10
  • Issue posted in working example to add this information. https://github.com/Whiffer/SwiftUI-PopToRootExample/issues/2 – Awais Fayyaz Jun 30 '22 at 13:20
  • Thanks. I added a clarification to the README for the example. – Chuck H Jul 02 '22 at 18:17
  • That's the most elegant solution, thanks Chuck. – Muhammad Umair Sep 10 '22 at 10:12
  • Thank you so much!. I had similar need but from a toolbar navigation. Also could do without much of the code, so forked your project here - https://github.com/niravdinmali/SwiftUI-PopToRootExample-Minimal-ToolBarNavigation – nirav dinmali Sep 23 '22 at 07:59
41

Since currently SwiftUI still uses a UINavigationController in the background it is also possible to call its popToRootViewController(animated:) function. You only have to search the view controller hierarchy for the UINavigationController like this:

struct NavigationUtil {
  static func popToRootView() {
    findNavigationController(viewController: UIApplication.shared.windows.filter { $0.isKeyWindow }.first?.rootViewController)?
      .popToRootViewController(animated: true)
  }

  static func findNavigationController(viewController: UIViewController?) -> UINavigationController? {
    guard let viewController = viewController else {
      return nil
    }

    if let navigationController = viewController as? UINavigationController {
      return navigationController
    }

    for childViewController in viewController.children {
      return findNavigationController(viewController: childViewController)
    }

    return nil
  }
}

And use it like this:

struct ContentView: View {
    var body: some View {
      NavigationView { DummyView(number: 1) }
    }
}

struct DummyView: View {
  let number: Int

  var body: some View {
    VStack(spacing: 10) {
      Text("This is view \(number)")
      NavigationLink(destination: DummyView(number: number + 1)) {
        Text("Go to view \(number + 1)")
      }
      Button(action: { NavigationUtil.popToRootView() }) {
        Text("Or go to root view!")
      }
    }
  }
}
x0randgat3
  • 411
  • 4
  • 3
  • 1
    Worked on my end! Thank you – vidalbenjoe Jul 21 '21 at 01:43
  • 1
    Still works. Well, maybe it will not in the future. But why not have an easy life now. Feels like the most natural way to to it. – brainray Oct 27 '21 at 18:01
  • 1
    Stopped working here for some reason... – brainray Nov 03 '21 at 12:27
  • 8
    This seems to only work with one view with a NavigationView. If you have a TabView of a multiple views with NavigationView, it only works with the first one – fballjeff Mar 10 '22 at 21:05
  • Just saying that seems like this is the only solutions that applies to `View` stack when it begins within a `List` (e.g. there isn't any `$isPresented` in `NavigationLink` call in that case, since it’s bugging with it). And this additional code make it just works. Thanks for that solution. – Yaroslav Y. Jun 17 '22 at 14:50
  • Hello, I have a case like this. in the ContentView file, it contains a NavigationView which has a Home and Profile page, then the Profile page has an Address List page, then the Address List has 3 more pages, namely the page for adding addresses, namely the Province, City, District election page and the Address Details page. When the User finishes filling in the Address Details, I want to return to the Address List Page, not the Profile. I used the method you described but it didn't work. Can you help me? – Ifadak Sep 12 '22 at 11:13
  • This was by far the easiest to implement and still works in iOS 16 when modified to search through the UIApplication.shared.connectedScenes. When the NavigationStack is nested inside a TabView you just have to check for the presence of a UITabController then use its .selectedViewController to find the active UINavigationController. – WaitsAtWork Oct 24 '22 at 21:29
  • 1
    @WaitsAtWork could you please share your updated solution? Does it work with NavigationViews nested inside a TabView? – Olivia Brown Dec 19 '22 at 21:58
24

Introducing Apple's solution to this very problem

It also presented to you via HackingWithSwift (which I stole this from, LOL) under programmatic navigation:

(Tested on Xcode 12 and iOS 14)

Essentially, you use tag and selection inside navigationlink to go straight to whatever page you want.

struct ContentView: View {
    @State private var selection: String? = nil

    var body: some View {
        NavigationView {
            VStack {
                NavigationLink(destination: Text("Second View"), tag: "Second", selection: $selection) { EmptyView() }
                NavigationLink(destination: Text("Third View"), tag: "Third", selection: $selection) { EmptyView() }
                Button("Tap to show second") {
                    self.selection = "Second"
                }
                Button("Tap to show third") {
                    self.selection = "Third"
                }
            }
            .navigationBarTitle("Navigation")
        }
    }
}

You can use an @environmentobject injected into ContentView() to handle the selection:

class NavigationHelper: ObservableObject {
    @Published var selection: String? = nil
}

inject into App:

@main
struct YourApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView().environmentObject(NavigationHelper())
        }
    }
}

and use it:

struct ContentView: View {
    @EnvironmentObject var navigationHelper: NavigationHelper

    var body: some View {
        NavigationView {
            VStack {
                NavigationLink(destination: Text("Second View"), tag: "Second", selection: $navigationHelper.selection) { EmptyView() }
                NavigationLink(destination: Text("Third View"), tag: "Third", selection: $navigationHelper.selection) { EmptyView() }
                Button("Tap to show second") {
                    self.navigationHelper.selection = "Second"
                }
                Button("Tap to show third") {
                    self.navigationHelper.selection = "Third"
                }
            }
            .navigationBarTitle("Navigation")
        }
    }
}

To go back to contentview in child navigationlinks, you just set the navigationHelper.selection = nil.

Note you don't even have to use tag and selection for subsequent child nav links if you don't want to—they will not have functionality to go to that specific navigationLink though.

Peter Mortensen
  • 30,738
  • 21
  • 105
  • 131
Super Noob
  • 750
  • 8
  • 17
  • The issue that I'm facing is when I go back to contentview in child navigationlinks by setting the `navigationHelper.selection = nil` it doesn't lazily load my ContentView. So the variables are not updated within the ContentView from the additional information generated in the child views. Any ideas on how to fix this? – JLively Nov 01 '20 at 13:20
  • @JLively maybe just manually reset the data when user clicks to contentview? – Super Noob Nov 01 '20 at 20:15
  • Works great for me. – cvb Nov 04 '20 at 20:33
  • Yeah, this solution does work. I've just found the answer why it wasn't working originally. – JLively Nov 05 '20 at 21:11
  • 1
    Only root > child1 works fine. When child1 > child2, it automatically goes back to the root view. – Kenan Karakecili Jan 05 '21 at 00:50
  • Been looking for a programmatic way to do this. Works great – AJ Aguasin Jan 05 '21 at 09:11
  • 1
    @KenanKarakecili Yeah I have no idea why it does this.. but deleting `tag:` & `selection:` within child1 will prevent it going back to root (`nil`) when popping to child2.. however this means you won't be able to go to child2 by setting child2's `tag` into `navigationHelper.selection` – Super Noob Mar 21 '21 at 11:11
  • How do you set the tag on the root ViewController? – ScottyBlades Apr 29 '21 at 00:37
  • When have 3 views, the third one pops me to root view. Any idea why? – Foriger Nov 17 '21 at 08:48
  • I have a list of views populated by an API and most of the time when I click any item in the list it goes to the child and pops me back to the list. – lcj Sep 24 '22 at 11:18
  • I added .navigationViewStyle(.stack) to the NavigationLink and it started working for me based on this post https://stackoverflow.com/questions/70291367/swiftui-navigationlink-pops-back-when-variable-is-updated – lcj Sep 24 '22 at 13:17
  • I think the reason it pops back to root is because the interstitial view is no longer considered "active". You can reproduce that behavior by using `isActive` and updating the `isActive` on the first `NavigationLink` in the stack to `false`. I still like this solution in general so I'm now storing an array of the enum values I created for `selection`. I then created a wrapper for `NavigationLink` that monitors the array and sets a local state `isActive` if that link's value exists in the array. Popping to root involves removing the first from the array then setting it to []. – CEarwood Jan 13 '23 at 07:42
  • this is good simple solution, but was deprecated in iOS 16.0 :( – zslavman Jul 30 '23 at 17:57
11

As far as I can see, there isn't any easy way to do it with the current beta 5. The only way I found is very hacky, but it works.

Basically, add a publisher to your DetailViewA which will be triggered from DetailViewB. In DetailViewB dismiss the view and inform the publisher, which itself will close DetailViewA.

    struct DetailViewB: View {
    @Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
    var publisher = PassthroughSubject<Void, Never>()

    var body: some View {
        VStack {
            Text("This is Detail View B.")

            Button(action: { self.presentationMode.value.dismiss() } )
            { Text("Pop to Detail View A.") }

            Button(action: {
                DispatchQueue.main.async {
                self.presentationMode.wrappedValue.dismiss()
                self.publisher.send()
                }
            } )
            { Text("Pop two levels to Master View.") }

        }
    }
}

struct DetailViewA: View {
    @Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
    var publisher = PassthroughSubject<Void, Never>()

    var body: some View {
        VStack {
            Text("This is Detail View A.")

            NavigationLink(destination: DetailViewB(publisher:self.publisher) )
            { Text("Push to Detail View B.") }

            Button(action: { self.presentationMode.value.dismiss() } )
            { Text("Pop one level to Master.") }
        }
        .onReceive(publisher, perform: { _ in
            DispatchQueue.main.async {
                print("Go Back to Master")
                self.presentationMode.wrappedValue.dismiss()
            }
        })
    }
}

And Beta 6 still doesn't have a solution.

I found another way to go back to the root, but this time I'm losing the animation, and go straight to the root. The idea is to force a refresh of the root view, this way leading to a cleaning of the navigation stack.

But ultimately only Apple could bring a proper solution, as the management of the navigation stack is not available in SwiftUI.

NB: The simple solution by notification below works on iOS, not watchOS, as watchOS clears the root view from memory after two navigation levels. But having an external class managing the state for watchOS should just work.

struct DetailViewB: View {
    @Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>

    @State var fullDissmiss:Bool = false
    var body: some View {
        SGNavigationChildsView(fullDissmiss: self.fullDissmiss){
            VStack {
                Text("This is Detail View B.")

                Button(action: { self.presentationMode.wrappedValue.dismiss() } )
                { Text("Pop to Detail View A.") }

                Button(action: {
                    self.fullDissmiss = true
                } )
                { Text("Pop two levels to Master View with SGGoToRoot.") }
            }
        }
    }
}

struct DetailViewA: View {
    @Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>

    @State var fullDissmiss:Bool = false
    var body: some View {
        SGNavigationChildsView(fullDissmiss: self.fullDissmiss){
            VStack {
                Text("This is Detail View A.")

                NavigationLink(destination: DetailViewB() )
                { Text("Push to Detail View B.") }

                Button(action: { self.presentationMode.wrappedValue.dismiss() } )
                { Text("Pop one level to Master.") }

                Button(action: { self.fullDissmiss = true } )
                { Text("Pop one level to Master with SGGoToRoot.") }
            }
        }
    }
}

struct MasterView: View {
    var body: some View {
        VStack {
            Text("This is Master View.")
            NavigationLink(destination: DetailViewA() )
            { Text("Push to Detail View A.") }
        }
    }
}

struct ContentView: View {

    var body: some View {
        SGRootNavigationView{
            MasterView()
        }
    }
}
#if DEBUG
struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}
#endif

struct SGRootNavigationView<Content>: View where Content: View {
    let cancellable = NotificationCenter.default.publisher(for: Notification.Name("SGGoToRoot"), object: nil)

    let content: () -> Content

    init(@ViewBuilder content: @escaping () -> Content) {
        self.content = content
    }

    @State var goToRoot:Bool = false

    var body: some View {
        return
            Group{
            if goToRoot == false{
                NavigationView {
                content()
                }
            }else{
                NavigationView {
                content()
                }
            }
            }.onReceive(cancellable, perform: {_ in
                DispatchQueue.main.async {
                    self.goToRoot.toggle()
                }
            })
    }
}

struct SGNavigationChildsView<Content>: View where Content: View {
    let notification = Notification(name: Notification.Name("SGGoToRoot"))

    var fullDissmiss:Bool{
        get{ return false }
        set{ if newValue {self.goToRoot()} }
    }

    let content: () -> Content

    init(fullDissmiss:Bool, @ViewBuilder content: @escaping () -> Content) {
        self.content = content
        self.fullDissmiss = fullDissmiss
    }

    var body: some View {
        return Group{
            content()
        }
    }

    func goToRoot(){
        NotificationCenter.default.post(self.notification)
    }
}
Peter Mortensen
  • 30,738
  • 21
  • 105
  • 131
Fabrice Leyne
  • 759
  • 5
  • 12
  • Thanks. I'm glad to see that it can be done. You're right it is a little hacky, but it does work. It would be best if DetailViewA didn't flash by on the way back to the MasterView. We can hope that Apple fills this and a few other holes in the SwiftUI navigation model in an upcoming beta. – Chuck H Aug 16 '19 at 19:19
9

IOS 16 Solution

Now finally you can pop to the root view with the newly added NavigationStack!!!

struct DataObject: Identifiable, Hashable {
    let id = UUID()
    let name: String
}

@available(iOS 16.0, *)
struct ContentView8: View {
    @State private var path = NavigationPath()
    
    var body: some View {
        NavigationStack(path: $path) {
            Text("Root Pop")
                .font(.largeTitle)
                .foregroundColor(.primary)
            
            NavigationLink("Click Item", value: DataObject.init(name: "Item"))
            
            .listStyle(.plain)
            .navigationDestination(for: DataObject.self) { course in
                Text(course.name)
                NavigationLink("Go Deeper", value: DataObject.init(name: "Item"))
                Button("Back to root") {
                    path = NavigationPath()
                }
            }
        }
        .padding()
    }
}
micah
  • 838
  • 7
  • 21
  • 1
    This is the most correct approach in my opinion and it is inline with the current version of Apple's documentation for NavigationStack & NavigationLink. Note that you could pass the "path" state var as a binding to nested views, which will allow them to pop to any view (in the stack) by manipulating the elements in it (removing all elements as in the above answer pops to the root view). – DCDC Aug 07 '23 at 22:41
8

I figured out a simple solution to pop to the root view. I am sending a notification and then listening for the notification to change the id of the NavigationView; this will refresh the NavigationView. There is not an animation, but it looks good. Here is the example:

@main
struct SampleApp: App {
    @State private var navigationId = UUID()

    var body: some Scene {
        WindowGroup {
            NavigationView {
                Screen1()
            }
            .id(navigationId)
            .onReceive(NotificationCenter.default.publisher(for: Notification.Name("popToRootView"))) { output in
                navigationId = UUID()
            }
        }
    }
}

struct Screen1: View {
    var body: some View {
        VStack {
            Text("This is screen 1")
            NavigationLink("Show Screen 2", destination: Screen2())
        }
    }
}

struct Screen2: View {
    var body: some View {
        VStack {
            Text("This is screen 2")
            Button("Go to Home") {
                NotificationCenter.default.post(name: Notification.Name("popToRootView"), object: nil)
            }
        }
    }
}
Peter Mortensen
  • 30,738
  • 21
  • 105
  • 131
  • Gustavo thank you for your answer. While this technique can work, it is not the best technique to use with SwiftUI. The preferred method with SwiftUI is to use @State vars to make things happen. – Chuck H Feb 27 '21 at 02:51
  • Here (see link) you can find similar solution but by using @EnvironmentObject instead of NotificationCenter... https://www.cuvenx.com/post/swiftui-pop-to-root-view – Serg Jan 26 '22 at 20:32
  • Type 'Notification' has no member 'Name'. I got this error in Xcode 14.0 – Bijender Singh Shekhawat Oct 18 '22 at 16:57
6

This is an update to x0randgat3's answer that works for multiple NavigationViews within a TabView.

struct NavigationUtil {
  static func popToRootView() {
    findNavigationController(viewController: UIApplication.shared.windows.filter { $0.isKeyWindow }.first?.rootViewController)?
      .popToRootViewController(animated: true)
  }

  static func findNavigationController(viewController: UIViewController?) -> UINavigationController? {
    guard let viewController = viewController else {
      return nil
    }

    if let navigationController = viewController as? UITabBarController {
      return findNavigationController(viewController: navigationController.selectedViewController)
    }

    if let navigationController = viewController as? UINavigationController {
      return navigationController
    }

    for childViewController in viewController.children {
      return findNavigationController(viewController: childViewController)
    }

    return nil
  }
}
Peter Mortensen
  • 30,738
  • 21
  • 105
  • 131
marceltex
  • 61
  • 1
  • 2
  • I tried this approach. It is not working for me. I called the same method in Network Manger? – Ramgopal Jun 16 '22 at 13:17
  • 2
    Works for me. Except 'windows' was deprecated in iOS 15.0. Fixed by updating the following: findNavigationController(viewController: UIApplication.shared.connectedScenes.flatMap { ($0 as? UIWindowScene)?.windows ?? [] }.first { $0.isKeyWindow }?.rootViewController)? – codingCartooningCPA Feb 21 '23 at 19:11
5

I figured out how to use complex navigation in SwiftUI. The trick is to collect all the states of your views, which tell if they are shown.

Start by defining a NavigationController. I have added the selection for the tabview tab and the Boolean values saying if a specific view is shown:

import SwiftUI

final class NavigationController: ObservableObject  {

  @Published var selection: Int = 1

  @Published var tab1Detail1IsShown = false
  @Published var tab1Detail2IsShown = false

  @Published var tab2Detail1IsShown = false
  @Published var tab2Detail2IsShown = false
}

Setting up the tabview with two tabs and binding our NavigationController.selection to the tabview:

import SwiftUI

struct ContentView: View {

  @EnvironmentObject var nav: NavigationController

  var body: some View {

    TabView(selection: self.$nav.selection) {

      FirstMasterView()
      .tabItem {
        Text("First")
      }
      .tag(0)

      SecondMasterView()
      .tabItem {
        Text("Second")
      }
      .tag(1)
    }
  }

}

As an example, this is one navigationStacks

import SwiftUI

struct FirstMasterView: View {

  @EnvironmentObject var nav: NavigationController

  var body: some View {
    NavigationView {
      VStack {

        NavigationLink(destination: FirstDetailView(), isActive: self.$nav.tab1Detail1IsShown) {
          Text("go to first detail")
        }
      } .navigationBarTitle(Text("First MasterView"))
    }
  }
}

struct FirstDetailView: View {

  @EnvironmentObject var nav: NavigationController
  @Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>

  var body: some View {

    VStack(spacing: 20) {
      Text("first detail View").font(.title)

      NavigationLink(destination: FirstTabLastView(), isActive: self.$nav.tab1Detail2IsShown) {
        Text("go to last detail on nav stack")
      }

      Button(action: {
        self.nav.tab2Detail1IsShown = false // true will go directly to detail
        self.nav.tab2Detail2IsShown = false

        self.nav.selection = 1
      }) {
        Text("Go to second tab")
      }
    }

    // In case of collapsing all the way back
    // there is a bug with the environment object
    // to go all the way back I have to use the presentationMode
    .onReceive(self.nav.$tab1Detail2IsShown, perform: { (out) in
      if out ==  false {
        self.presentationMode.wrappedValue.dismiss()
      }
    })
  }
}

struct FirstTabLastView: View {
  @EnvironmentObject var nav: NavigationController

  var body: some View {
    Button(action: {
      self.nav.tab1Detail1IsShown = false
      self.nav.tab1Detail2IsShown = false
    }) {
      Text("Done and go back to beginning of navigation stack")
    }
  }
}

This approach is quite SwiftUI-state oriented.

Peter Mortensen
  • 30,738
  • 21
  • 105
  • 131
kprater
  • 439
  • 4
  • 4
  • Creating a NavigationController and putting it into an EnvironmentObject is a very good idea. I don't quite have your example completely working yet, but I think it's on the right track. Thanks. – Chuck H Aug 29 '19 at 05:00
  • I realized that I need one more var to make sure the collapse does not always happen for the last view on the stack. I added my project here. https://github.com/gahntpo/NavigationSwiftUI.git – kprater Aug 29 '19 at 07:50
  • This is a great idea, but how would this work in a List? For me every item in the list will open a detail view because isActive is set to true for every NavigationLink. – Thomas Vos Oct 28 '19 at 09:30
  • 2
    If you want to use a list, the approach is fairly similar. I would not put the NavigationLink inside the List (since this creates different links, as you mentioned). You can add a programmatical link (means you don't have a visible button). NavigationLink(destination: MyView(data: mySelectedDataFromTheList), isActive: $self.nav.isShown) { EmptyView()}. When the user tabs on an item in the list you can set the mySelectedDataFromTheList to the tabbed item and change the navigation sate isShown to true. – kprater Nov 18 '19 at 09:14
  • I finally took the time to write a blog post about Navigation in SwiftUI. This explains it a bit more and shows some use cases. https://medium.com/@karinprater/how-to-make-navigation-in-swiftui-a-piece-of-cake-223b2a40c6c1 – kprater Jun 22 '20 at 05:31
  • I implemented this and it's working (going back to root), but when the navigation is more complicated and in multiple depth, I found that sometimes pop animation shows for intermediate views, and also sometimes I find that navigation gets out of order when navigating to detail screens again. – green0range Oct 09 '20 at 16:44
4

For me, in order to achieve full control for the navigation that is still missing in SwiftUI, I just embedded the SwiftUI View inside a UINavigationController. inside the SceneDelegate. Take note that I hide the navigation bar in order to use the NavigationView as my display.

class SceneDelegate: UIResponder, UIWindowSceneDelegate {

    var window: UIWindow?

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {

        UINavigationBar.appearance().tintColor = .black

        let contentView = OnBoardingView()
        if let windowScene = scene as? UIWindowScene {
            let window = UIWindow(windowScene: windowScene)
            let hostingVC = UIHostingController(rootView: contentView)
            let mainNavVC = UINavigationController(rootViewController: hostingVC)
            mainNavVC.navigationBar.isHidden = true
            window.rootViewController = mainNavVC
            self.window = window
            window.makeKeyAndVisible()
        }
    }
}

And then I have created this Protocol and Extension, HasRootNavigationController

import SwiftUI
import UIKit

protocol HasRootNavigationController {
    var rootVC:UINavigationController? { get }

    func push<Content:View>(view: Content, animated:Bool)
    func setRootNavigation<Content:View>(views:[Content], animated:Bool)
    func pop(animated: Bool)
    func popToRoot(animated: Bool)
}

extension HasRootNavigationController where Self:View {

    var rootVC:UINavigationController? {
        guard let scene = UIApplication.shared.connectedScenes.first,
            let sceneDelegate = scene as? UIWindowScene,
            let rootvc = sceneDelegate.windows.first?.rootViewController
                as? UINavigationController else { return nil }
        return rootvc
    }

    func push<Content:View>(view: Content, animated:Bool = true) {
        rootVC?.pushViewController(UIHostingController(rootView: view), animated: animated)
    }

    func setRootNavigation<Content:View>(views: [Content], animated:Bool = true) {
        let controllers =  views.compactMap { UIHostingController(rootView: $0) }
        rootVC?.setViewControllers(controllers, animated: animated)
    }

    func pop(animated:Bool = true) {
        rootVC?.popViewController(animated: animated)
    }

    func popToRoot(animated: Bool = true) {
        rootVC?.popToRootViewController(animated: animated)
    }
}

After that, on my SwiftUI View, I used/implemented the HasRootNavigationController protocol and extension

extension YouSwiftUIView:HasRootNavigationController {

    func switchToMainScreen() {
        self.setRootNavigation(views: [MainView()])
    }

    func pushToMainScreen() {
         self.push(view: [MainView()])
    }

    func goBack() {
         self.pop()
    }

    func showTheInitialView() {
         self.popToRoot()
    }
}

Here is the gist of my code in case I have some updates. https://gist.github.com/michaelhenry/945fc63da49e960953b72bbc567458e6

Peter Mortensen
  • 30,738
  • 21
  • 105
  • 131
Michael Henry
  • 644
  • 9
  • 12
  • 1
    This is the solution that best fit my needs, as it allowed me my current navigation stack with minimal changes. Something that would improve this even more is a quick example of a navigation stack using this on the gist, as it took some figuring out to get it working (namely having to call `setRootNavigation` & when) – Gannon Prudhomme Feb 04 '20 at 00:03
  • This solution is fantastic, but using it I still haven't found a way to implement `NavigationView` and `.navigationBarItems` modifier. I have to modify the UINavigationBar everytime. Plus, you have to pass the environmentObjects for every view you push. – riciloma May 12 '20 at 14:25
  • Brilliant solution, helps to keep the views reusable without passing the unwanted parameters. – Liubo Aug 02 '20 at 11:32
  • Thanks. Push requires `View` instead of array of `View`. So `self.push(view: [MainView()])` should be `self.push(view: MainView())` – iRiziya May 29 '21 at 05:22
  • I am trying to use this for `TabView`. I called appropriate function on tab item click but it does not seem working. Any thoughts? @MichaelHenry – iRiziya May 29 '21 at 05:36
4

Thanks to Malhal's @Binding solution, I learned I was missing the .isDetailLink(false) modifier.

In my case, I don't want to use the @Binding at every subsequent view.

This is my solution where I am using EnvironmentObject.

Step 1: Create an AppState ObservableObject

import SwiftUI
import Combine

class AppState: ObservableObject {
    @Published var moveToDashboard: Bool = false
}

Step 2: Create instance of AppState and add in contentView in SceneDelegate

func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
    // Create the SwiftUI view that provides the window contents.
    let contentView = ContentView()
    let appState = AppState()

    // Use a UIHostingController as window root view controller.
    if let windowScene = scene as? UIWindowScene {
        let window = UIWindow(windowScene: windowScene)
        window.rootViewController = UIHostingController(rootView:
            contentView
                .environmentObject(appState)
        )
        self.window = window
        window.makeKeyAndVisible()
    }
}

Step 3: Code of ContentView.swift

I am updating the appState value of the last view in the Stack which using .onReceive() I am capturing in the contentView to update the isActive to false for the NavigationLink.

The key here is to use .isDetailLink(false) with the NavigationLink. Otherwise, it will not work.

import SwiftUI
import Combine

class AppState: ObservableObject {
    @Published var moveToDashboard: Bool = false
}

struct ContentView: View {
    @EnvironmentObject var appState: AppState
    @State var isView1Active: Bool = false

    var body: some View {
        NavigationView {
            VStack {
                Text("Content View")
                    .font(.headline)

                NavigationLink(destination: View1(), isActive: $isView1Active) {
                    Text("View 1")
                        .font(.headline)
                }
                .isDetailLink(false)
            }
            .onReceive(self.appState.$moveToDashboard) { moveToDashboard in
                if moveToDashboard {
                    print("Move to dashboard: \(moveToDashboard)")
                    self.isView1Active = false
                    self.appState.moveToDashboard = false
                }
            }
        }
    }
}

// MARK:- View 1
struct View1: View {

    var body: some View {
        VStack {
            Text("View 1")
                .font(.headline)
            NavigationLink(destination: View2()) {
                Text("View 2")
                    .font(.headline)
            }
        }
    }
}

// MARK:- View 2
struct View2: View {
    @EnvironmentObject var appState: AppState

    var body: some View {
        VStack {
            Text("View 2")
                .font(.headline)
            Button(action: {
                self.appState.moveToDashboard = true
            }) {
                Text("Move to Dashboard")
                .font(.headline)
            }
        }
    }
}


struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

Enter image description here

Peter Mortensen
  • 30,738
  • 21
  • 105
  • 131
Mahmud Ahsan
  • 1,755
  • 19
  • 18
4

This solution is based on malhal's answer, uses suggestions from Imthath and Florin Odagiu, and required Paul Hudson's NavigationView video to bring it all together for me.

The idea is very simple. The isActive parameter of a navigationLink is set to true when tapped. That allows a second view to appear. You can use additional links to add more views. To go back to the root, just set isActive to false. The second view, plus any others that may have stacked up, disappear.

import SwiftUI

class Views: ObservableObject {
    @Published var stacked = false
}

struct ContentView: View {
    @ObservedObject var views = Views()

    var body: some View {
        NavigationView {
            NavigationLink(destination: ContentView2(), isActive: self.$views.stacked) {
                Text("Go to View 2") // Tapping this link sets stacked to true
            }
            .isDetailLink(false)
            .navigationBarTitle("ContentView")
        }
        .environmentObject(views) // Inject a new views instance into the navigation view environment so that it's available to all views presented by the navigation view.
    }
}

struct ContentView2: View {

    var body: some View {
        NavigationLink(destination: ContentView3()) {
            Text("Go to View 3")
        }
        .isDetailLink(false)
        .navigationBarTitle("View 2")
    }
}

struct ContentView3: View {
    @EnvironmentObject var views: Views

    var body: some View {

        Button("Pop to root") {
            self.views.stacked = false // By setting this to false, the second view that was active is no more. Which means, the content view is being shown once again.
        }
        .navigationBarTitle("View 3")
    }
}
Peter Mortensen
  • 30,738
  • 21
  • 105
  • 131
squarehippo10
  • 1,855
  • 1
  • 15
  • 45
3

Here is my solution. IT works anywhere, without dependency.

let window = UIApplication.shared.connectedScenes
  .filter { $0.activationState == .foregroundActive }
  .map { $0 as? UIWindowScene }
  .compactMap { $0 }
  .first?.windows
  .filter { $0.isKeyWindow }
  .first
let nvc = window?.rootViewController?.children.first as? UINavigationController
nvc?.popToRootViewController(animated: true)
Peter Mortensen
  • 30,738
  • 21
  • 105
  • 131
Tomasen
  • 142
  • 1
  • 3
3

NavigationViewKit

import NavigationViewKit
NavigationView {
    List(0..<10) { _ in
        NavigationLink("abc", destination: DetailView())
    }
}
.navigationViewManager(for: "nv1", afterBackDo: {print("back to root") })

In any view in NavigationView:

@Environment(\.navigationManager) var nvmanager

Button("back to root view") {
    nvmanager.wrappedValue.popToRoot(tag:"nv1") {
        print("other back")
    }
}

You can also call it through NotificationCenter without calling it in the view

let backToRootItem = NavigationViewManager.BackToRootItem(tag: "nv1", animated: false, action: {})
NotificationCenter.default.post(name: .NavigationViewManagerBackToRoot, object: backToRootItem)
Peter Mortensen
  • 30,738
  • 21
  • 105
  • 131
Bob Xu
  • 129
  • 2
  • 9
3

There is a simple solution in iOS 15 for that by using dismiss() and passing dismiss to the subview:

struct ContentView: View {
    @State private var showingSheet = false
    var body: some View {
        NavigationView {
            Button("show sheet", action: { showingSheet.toggle()})
                .navigationTitle("ContentView")
        }.sheet(isPresented: $showingSheet) { FirstSheetView() }
    }
}

struct FirstSheetView: View {
    @Environment(\.dismiss) var dismiss
    var body: some View {
        NavigationView {
            List {
                NavigationLink(destination: SecondSheetView(dismiss: _dismiss)) {
                    Text("show 2nd Sheet view")
                }
                NavigationLink(destination: ThirdSheetView(dismiss: _dismiss)) {
                    Text("show 3rd Sheet view")
                }
                Button("cancel", action: {dismiss()})
            } .navigationTitle("1. SheetView")
        }
    }
}

struct SecondSheetView: View {
    @Environment(\.dismiss) var dismiss
    var body: some View {
        List {
            NavigationLink(destination: ThirdSheetView(dismiss: _dismiss)) {
                Text("show 3rd SheetView")
            }
            Button("cancel", action: {dismiss()})
        } .navigationTitle("2. SheetView")
    }
}

struct ThirdSheetView: View {
    @Environment(\.dismiss) var dismiss
    var body: some View {
        List {
            Button("cancel", action: {dismiss()})
        } .navigationTitle("3. SheetView")
    }
}
Peter Mortensen
  • 30,738
  • 21
  • 105
  • 131
david
  • 31
  • 2
2

Here is my slow, animated, a bit rough backwards pop solution using onAppear, valid for Xcode 11 and iOS 13.1:

import SwiftUI
import Combine


struct NestedViewLevel3: View {
    @Binding var resetView:Bool
    @Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>

    var body: some View {
        VStack {
            Spacer()
            Text("Level 3")
            Spacer()
            Button(action: {
                self.presentationMode.wrappedValue.dismiss()
            }) {
                Text("Back")
                    .padding(.horizontal, 15)
                    .padding(.vertical, 2)
                    .foregroundColor(Color.white)
                    .clipped(antialiased: true)
                    .background(
                        RoundedRectangle(cornerRadius: 20)
                            .foregroundColor(Color.blue)
                            .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: 40, alignment: .center)
                )}
            Spacer()
            Button(action: {
                self.$resetView.wrappedValue = true
                self.presentationMode.wrappedValue.dismiss()
            }) {
                Text("Reset")
                    .padding(.horizontal, 15)
                    .padding(.vertical, 2)
                    .foregroundColor(Color.white)
                    .clipped(antialiased: true)
                    .background(
                        RoundedRectangle(cornerRadius: 20)
                            .foregroundColor(Color.blue)
                            .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: 40, alignment: .center)
                )}
            Spacer()
        }
        .navigationBarBackButtonHidden(false)
        .navigationBarTitle("Level 3", displayMode: .inline)
        .onAppear(perform: {print("onAppear level 3")})
        .onDisappear(perform: {print("onDisappear level 3")})
    }
}

struct NestedViewLevel2: View {
    @Binding var resetView:Bool
    @Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>

    var body: some View {
        VStack {
            Spacer()
            NavigationLink(destination: NestedViewLevel3(resetView:$resetView)) {
                Text("To level 3")
                    .padding(.horizontal, 15)
                    .padding(.vertical, 2)
                    .foregroundColor(Color.white)
                    .clipped(antialiased: true)
                    .background(
                        RoundedRectangle(cornerRadius: 20)
                            .foregroundColor(Color.gray)
                            .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: 40, alignment: .center)
                )
                    .shadow(radius: 10)
            }
            Spacer()
            Text("Level 2")
            Spacer()
            Button(action: {
                self.presentationMode.wrappedValue.dismiss()
            }) {
                Text("Back")
                    .padding(.horizontal, 15)
                    .padding(.vertical, 2)
                    .foregroundColor(Color.white)
                    .clipped(antialiased: true)
                    .background(
                        RoundedRectangle(cornerRadius: 20)
                            .foregroundColor(Color.blue)
                            .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: 40, alignment: .center)
                )}
            Spacer()
        }
        .navigationBarBackButtonHidden(false)
        .navigationBarTitle("Level 2", displayMode: .inline)
        .onAppear(perform: {
            print("onAppear level 2")
            if self.$resetView.wrappedValue {
                self.presentationMode.wrappedValue.dismiss()
            }
        })
        .onDisappear(perform: {print("onDisappear level 2")})
    }
}

struct NestedViewLevel1: View {
    @Binding var resetView:Bool
    @Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>

    var body: some View {
        VStack {
            Spacer()
            NavigationLink(destination: NestedViewLevel2(resetView:$resetView)) {
                Text("To level 2")
                    .padding(.horizontal, 15)
                    .padding(.vertical, 2)
                    .foregroundColor(Color.white)
                    .clipped(antialiased: true)
                    .background(
                        RoundedRectangle(cornerRadius: 20)
                            .foregroundColor(Color.gray)
                            .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: 40, alignment: .center)
                )
                    .shadow(radius: 10)
            }
            Spacer()
            Text("Level 1")
            Spacer()
            Button(action: {
                self.presentationMode.wrappedValue.dismiss()
            }) {
                Text("Back")
                    .padding(.horizontal, 15)
                    .padding(.vertical, 2)
                    .foregroundColor(Color.white)
                    .clipped(antialiased: true)
                    .background(
                        RoundedRectangle(cornerRadius: 20)
                            .foregroundColor(Color.blue)
                            .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: 40, alignment: .center)
            )}
            Spacer()
        }
        .navigationBarBackButtonHidden(false)
        .navigationBarTitle("Level 1", displayMode: .inline)
        .onAppear(perform: {
            print("onAppear level 1")
            if self.$resetView.wrappedValue {
                self.presentationMode.wrappedValue.dismiss()
            }
        })
        .onDisappear(perform: {print("onDisappear level 1")})
    }
}

struct RootViewLevel0: View {
    @Binding var resetView:Bool
    var body: some View {
        NavigationView {
            VStack {
                Spacer()
                NavigationLink(destination: NestedViewLevel1(resetView:$resetView)) {
                Text("To level 1")
                    .padding(.horizontal, 15)
                    .padding(.vertical, 2)
                    .foregroundColor(Color.white)
                    .clipped(antialiased: true)
                    .background(
                        RoundedRectangle(cornerRadius: 20)
                        .foregroundColor(Color.gray)
                        .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: 40, alignment: .center)
                    )
                    .shadow(radius: 10)
                }
                //.disabled(false)
                //.hidden()
                Spacer()
            }
        }
        //.frame(width:UIScreen.main.bounds.width,height:  UIScreen.main.bounds.height - 110)
        .navigationBarTitle("Root level 0", displayMode: .inline)
        .navigationBarBackButtonHidden(false)
        .navigationViewStyle(StackNavigationViewStyle())
        .onAppear(perform: {
            print("onAppear root level 0")
            self.resetNavView()
        })
        .onDisappear(perform: {print("onDisappear root level 0")})
    }

    func resetNavView(){
        print("resetting objects")
        self.$resetView.wrappedValue = false
    }

}


struct ContentView: View {
    @State var resetView = false
    var body: some View {
        RootViewLevel0(resetView:$resetView)
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}
Peter Mortensen
  • 30,738
  • 21
  • 105
  • 131
jpelayo
  • 403
  • 1
  • 5
  • 13
  • 1
    Hi @jpelayo, like your solution most. You could delete most of your code to make it easier to understand. The tricky part is simply to check a binded flag in `onAppear()` of all intermediate views. – jboi Nov 06 '19 at 11:46
2

I found a solution that works fine for me. Here is how it works:

A GIF image shows how it works

In the ContentView.swift file:

  1. define a RootSelection class, declare an @EnvironmentObject of RootSelection to record the tag of the current active NavigationLink only in root view.
  2. add a modifier .isDetailLink(false) to each NavigationLink that is not a final detail view.
  3. use a file system hierarchy to simulate the NavigationView.
  4. this solution works fine when the root view has multiple NavigationLink.
import SwiftUI

struct ContentView: View {
    var body: some View {
        NavigationView {
            SubView(folder: rootFolder)
        }
    }
}

struct SubView: View {
    @EnvironmentObject var rootSelection: RootSelection
    var folder: Folder

    var body: some View {
        List(self.folder.documents) { item in
            if self.folder.documents.count == 0 {
                Text("empty folder")
            } else {
                if self.folder.id == rootFolder.id {
                    NavigationLink(item.name, destination: SubView(folder: item as! Folder), tag: item.id, selection: self.$rootSelection.tag)
                        .isDetailLink(false)
                } else {
                    NavigationLink(item.name, destination: SubView(folder: item as! Folder))
                        .isDetailLink(false)
                }
            }
        }
        .navigationBarTitle(self.folder.name, displayMode: .large)
        .listStyle(SidebarListStyle())
        .overlay(
            Button(action: {
                rootSelection.tag = nil
            }, label: {
                Text("back to root")
            })
            .disabled(self.folder.id == rootFolder.id)
        )
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
            .environmentObject(RootSelection())
    }
}

class RootSelection: ObservableObject {
    @Published var tag: UUID? = nil
}

class Document: Identifiable {
    let id = UUID()
    var name: String

    init(name: String) {
        self.name = name
    }
}

class File: Document {}

class Folder: Document {
    var documents: [Document]

    init(name: String, documents: [Document]) {
        self.documents = documents
        super.init(name: name)
    }
}

let rootFolder = Folder(name: "root", documents: [
    Folder(name: "folder1", documents: [
        Folder(name: "folder1.1", documents: []),
        Folder(name: "folder1.2", documents: []),
    ]),
    Folder(name: "folder2", documents: [
        Folder(name: "folder2.1", documents: []),
        Folder(name: "folder2.2", documents: []),
    ])
])

.environmentObject(RootSelection()) is required for the ContentView() object in xxxApp.swift files.

import SwiftUI

@main
struct DraftApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(RootSelection())
        }
    }
}
Peter Mortensen
  • 30,738
  • 21
  • 105
  • 131
Sherman
  • 21
  • 2
2

At first, I was using the solution from the Chuck H that was posted here.

But I was faced with an issue when this solution didn't work in my case. It was connected to the case when the root view is a start point for two or more flows and at some point of these flows the user has the ability to do the pop to root. And in this case this solution didn't work because it has the one common state @Environment(\.rootPresentationMode) private var rootPresentationMode

I made the RouteManager with the additional enum Route which describes some specific flow where the user has the ability to do the pop to root

RouteManager:

final class RouteManager: ObservableObject {
    @Published
    private var routers: [Int: Route] = [:]

    subscript(for route: Route) -> Route? {
        get {
            routers[route.rawValue]
        }
        set {
            routers[route.rawValue] = route
        }
    }

    func select(_ route: Route) {
        routers[route.rawValue] = route
    }

    func unselect(_ route: Route) {
        routers[route.rawValue] = nil
    }
}

Route:

enum Route: Int, Hashable {
    case signUp
    case restorePassword
    case orderDetails
}

Usage:

struct ContentView: View {
    @EnvironmentObject
    var routeManager: RouteManager

    var body: some View {
        NavigationView {
            VStack {
                NavigationLink(
                    destination: SignUp(),
                    tag: .signUp,
                    selection: $routeManager[for: .signUp]
                ) { EmptyView() }.isDetailLink(false)
                NavigationLink(
                    destination: RestorePassword(),
                    tag: .restorePassword,
                    selection: $routeManager[for: .restorePassword]
                ) { EmptyView() }.isDetailLink(false)
                Button("Sign Up") {
                    routeManager.select(.signUp)
                }
                Button("Restore Password") {
                    routeManager.select(.restorePassword)
                }
            }
            .navigationBarTitle("Navigation")
            .onAppear {
                routeManager.unselect(.signUp)
                routeManager.unselect(.restorePassword)
            }
        }.navigationViewStyle(StackNavigationViewStyle())
    }
}

!! IMPORTANT !!

You should use the unselect method of the RouteManager when the user goes forward to the flow and then goes back by tapping on the back button. In this case, need to reset the state of our route manager for the previously selected flows to avoid undefined (unexpected) behavior:

.onAppear {
    routeManager.unselect(.signUp)
    routeManager.unselect(.restorePassword)
}

Demo

You can find a full demo project here.

Peter Mortensen
  • 30,738
  • 21
  • 105
  • 131
igdev
  • 341
  • 1
  • 4
  • 15
1

I came up with another technique which works but it still feels strange. It also still animates both screens dismissing, but it's a little cleaner. You can either A ) Pass a closure down to the subsequent detail screens or B ) pass detailB the presentationMode of detailA. Both of these require dismissing detailB, then delaying a short while so detailA is back on-screen before attempting to dismiss detailA.

let minDelay = TimeInterval(0.001)

struct ContentView: View {
    var body: some View {
        NavigationView {
            VStack {
                NavigationLink("Push Detail A", destination: DetailViewA())
            }.navigationBarTitle("Root View")
        }
    }
}

struct DetailViewA: View {
    @Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>

    var body: some View {
        VStack {
            Spacer()

            NavigationLink("Push Detail With Closure",
                           destination: DetailViewWithClosure(dismissParent: { self.dismiss() }))

            Spacer()

            NavigationLink("Push Detail with Parent Binding",
                           destination: DetailViewWithParentBinding(parentPresentationMode: self.presentationMode))

            Spacer()

        }.navigationBarTitle("Detail A")
    }

    func dismiss() {
        print ("Detail View A dismissing self.")
        presentationMode.wrappedValue.dismiss()
    }
}

struct DetailViewWithClosure: View {
    @Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>

    @State var dismissParent: () -> Void

    var body: some View {
        VStack {
            Button("Pop Both Details") { self.popParent() }
        }.navigationBarTitle("Detail With Closure")
    }

    func popParent() {
        presentationMode.wrappedValue.dismiss()
        DispatchQueue.main.asyncAfter(deadline: .now() + minDelay) { self.dismissParent() }
    }
}

struct DetailViewWithParentBinding: View {
    @Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>

    @Binding var parentPresentationMode: PresentationMode

    var body: some View {
        VStack {
            Button("Pop Both Details") { self.popParent() }
        }.navigationBarTitle("Detail With Binding")
    }

    func popParent() {
        presentationMode.wrappedValue.dismiss()
        DispatchQueue.main.asyncAfter(deadline: .now() + minDelay) { self.parentPresentationMode.dismiss() }
    }
}

The more I think about how SwiftUI works and how things are structured, the less I think Apple will provide something equivalent to popToRootViewController or other direct edits to the navigation stack. It flies in the face of the way SwiftUI builds up view structs because it lets a child view reach up into a parent's state and manipulate it. Which is exactly what these approaches do, but they do it explicitly and overtly. DetailViewA can't create either of the of the destination views without providing access into its own state, meaning the author has to think through the implications of providing said access.

Timothy Sanders
  • 206
  • 3
  • 7
  • The approach is straightforward and is basically working. I actually like it. The issue I came across is the toolbar in the grandparent could be broken if the minDelay is too short. In my case, the grandparent and parent have different toolbars, while the parent's toolbar could leave on the grandparent's toolbar, which means the grandparent's toolbar has not been updated when the view is back. – Freddie Mar 05 '23 at 00:43
1

I recently created an open source project called swiftui-navigation-stack. It's an alternative navigation stack for SwiftUI. Take a look at the README for all the details; it's really easy to use.

First of all, if you want to navigate between screens (i.e., fullscreen views) define your own simple Screen view:

struct Screen<Content>: View where Content: View {
    let myAppBackgroundColour = Color.white
    let content: () -> Content

    var body: some View {
        ZStack {
            myAppBackgroundColour.edgesIgnoringSafeArea(.all)
            content()
        }
    }
}

Then embed your root in a NavigationStackView (as you'd do with the standard NavigationView):

struct RootView: View {
    var body: some View {
        NavigationStackView {
            Homepage()
        }
    }
}

Now let's create a couple of child views just to show you the basic behaviour:

struct Homepage: View {
    var body: some View {
        Screen {
            PushView(destination: FirstChild()) {
                Text("PUSH FORWARD")
            }
        }
    }
}

struct FirstChild: View {
    var body: some View {
        Screen {
            VStack {
                PopView {
                    Text("JUST POP")
                }
                PushView(destination: SecondChild()) {
                    Text("PUSH FORWARD")
                }
            }
        }
    }
}

struct SecondChild: View {
    var body: some View {
        Screen {
            VStack {
                PopView {
                    Text("JUST POP")
                }
                PopView(destination: .root) {
                    Text("POP TO ROOT")
                }
            }
        }
    }
}

You can exploit PushView and PopView to navigate back and forth. Of course, your content view inside the SceneDelegate must be:

// Create the SwiftUI view that provides the window contents.
let contentView = RootView()

The result is:

Enter image description here

Peter Mortensen
  • 30,738
  • 21
  • 105
  • 131
superpuccio
  • 11,674
  • 8
  • 65
  • 93
1

Here's a generic approach for complex navigation which combines many approaches described here. This pattern is useful if you have many flows which need to pop back to the root and not just one.

First, set up your environment ObservableObject and for readability, use an enum to type your views.

class ActiveView : ObservableObject {
  @Published var selection: AppView? = nil
}

enum AppView : Comparable {
  case Main, Screen_11, Screen_12, Screen_21, Screen_22
}

[...]
let activeView = ActiveView()
window.rootViewController = UIHostingController(rootView: contentView.environmentObject(activeView))

In your main ContentView, use buttons with NavigationLink on EmptyView(). We do that to use the isActive parameter of NavigationLink instead of the tag and selection. Screen_11 on main view needs to remain active on Screen_12, and conversely, Screen_21 needs to remain active with Screen_22 or otherwise the views will pop out. Don't forget to set your isDetailLink to false.

struct ContentView: View {
  @EnvironmentObject private var activeView: ActiveView

  var body: some View {
    NavigationView {
      VStack {

        // These buttons navigate by setting the environment variable.
        Button(action: { self.activeView.selection = AppView.Screen_1.1}) {
            Text("Navigate to Screen 1.1")
        }

        Button(action: { self.activeView.selection = AppView.Screen_2.1}) {
            Text("Navigate to Screen 2.1")
        }

       // These are the navigation link bound to empty views so invisible
        NavigationLink(
          destination: Screen_11(),
          isActive: orBinding(b: self.$activeView.selection, value1: AppView.Screen_11, value2: AppView.Screen_12)) {
            EmptyView()
        }.isDetailLink(false)

        NavigationLink(
          destination: Screen_21(),
          isActive: orBinding(b: self.$activeView.selection, value1: AppView.Screen_21, value2: AppView.Screen_22)) {
            EmptyView()
        }.isDetailLink(false)
      }
    }
  }

You can use the same pattern on Screen_11 to navigate to Screen_12.

Now, the breakthrough for that complex navigation is the orBinding. It allows the stack of views on a navigation flow to remain active. Whether you are on Screen_11 or Screen_12, you need the NavigationLink(Screen_11) to remain active.

// This function create a new Binding<Bool> compatible with NavigationLink.isActive
func orBinding<T:Comparable>(b: Binding<T?>, value1: T, value2: T) -> Binding<Bool> {
  return Binding<Bool>(
      get: {
          return (b.wrappedValue == value1) || (b.wrappedValue == value2)
      },
      set: { newValue in  } // Don't care the set
    )
}
Peter Mortensen
  • 30,738
  • 21
  • 105
  • 131
1

Details

  • Xcode Version 13.2.1 (13C100), Swift 5.5

Solution

Linked list

https://github.com/raywenderlich/swift-algorithm-club/blob/master/Linked%20List/LinkedList.swift

NavigationStack

import SwiftUI
import Combine

//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// MARK: Custom NavigationLink
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////

final class CustomNavigationLinkViewModel<CustomViewID>: ObservableObject where CustomViewID: Equatable {
  private weak var navigationStack: NavigationStack<CustomViewID>?
  /// `viewId` is used to find a `CustomNavigationLinkViewModel` in the `NavigationStack`
  let viewId = UUID().uuidString
  
  /// `customId` is used to mark a `CustomNavigationLink` in the `NavigationStack`. This is kind of external id.
  /// In `NavigationStack` we always prefer to use `viewId`. But from time to time we need to implement `pop several views`
  /// and that is the purpose of the `customId`
  /// Developer can just create a link with `customId` e.g. `navigationStack.navigationLink(customId: "123") { .. }`
  /// And to pop directly to  view `"123"` should use `navigationStack.popToLast(customId: "123")`
  let customId: CustomViewID?

  @Published var isActive = false {
    didSet { navigationStack?.updated(linkViewModel: self) }
  }

  init (navigationStack: NavigationStack<CustomViewID>, customId: CustomViewID? = nil) {
    self.navigationStack = navigationStack
    self.customId = customId
  }
}

extension CustomNavigationLinkViewModel: Equatable {
  static func == (lhs: CustomNavigationLinkViewModel, rhs: CustomNavigationLinkViewModel) -> Bool {
    lhs.viewId == rhs.viewId && lhs.customId == rhs.customId
  }
}

struct CustomNavigationLink<Label, Destination, CustomViewID>: View where Label: View, Destination: View, CustomViewID: Equatable {

  /// Link `ViewModel` where all states are stored
  @StateObject var viewModel: CustomNavigationLinkViewModel<CustomViewID>

  let destination: () -> Destination
  let label: () -> Label

  var body: some View {
    NavigationLink(isActive: $viewModel.isActive, destination: destination, label: label)
  }
}

//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// MARK: NavigationStack
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////

class NavigationStack<CustomViewID>: ObservableObject where CustomViewID: Equatable {
  
  typealias Link = WeakReference<CustomNavigationLinkViewModel<CustomViewID>>
  private var linkedList = LinkedList<Link>()

  func navigationLink<Label, Destination>(customId: CustomViewID? = nil,
                                          @ViewBuilder destination: @escaping () -> Destination,
                                          @ViewBuilder label: @escaping () -> Label)
  -> some View where Label: View, Destination: View {
    createNavigationLink(customId: customId, destination: destination, label: label)
  }
  
  private func createNavigationLink<Label, Destination>(customId: CustomViewID? = nil,
                                                        @ViewBuilder destination: @escaping () -> Destination,
                                                        @ViewBuilder label: @escaping () -> Label)
  -> CustomNavigationLink<Label, Destination, CustomViewID> where Label: View, Destination: View {
    .init(viewModel: CustomNavigationLinkViewModel(navigationStack: self, customId: customId),
          destination: destination,
          label: label)
  }
}

// MARK: Nested Types

extension NavigationStack {
  /// To avoid retain cycle it is important to store weak reference to the `CustomNavigationLinkViewModel`
  final class WeakReference<T> where T: AnyObject {
    private(set) weak var weakReference: T?
    init(value: T) { self.weakReference = value }
    deinit { print("deinited WeakReference") }
  }
}

// MARK: Searching

extension NavigationStack {
  private func last(where condition: (Link) -> Bool) -> LinkedList<Link>.Node? {
    var node = linkedList.last
    while(node != nil) {
      if let node = node, condition(node.value) {
        return node
      }
      node = node?.previous
    }
    return nil
  }
}

// MARK: Binding

extension NavigationStack {
  fileprivate func updated(linkViewModel: CustomNavigationLinkViewModel<CustomViewID>) {
    guard linkViewModel.isActive else {
      switch linkedList.head?.value.weakReference {
      case nil: break
      case linkViewModel: linkedList.removeAll()
      default:
        last (where: { $0.weakReference === linkViewModel })?.previous?.next = nil
      }
      return
    }
    linkedList.append(WeakReference(value: linkViewModel))
  }
}

// MARK: pop functionality

extension NavigationStack {
  func popToRoot() {
    linkedList.head?.value.weakReference?.isActive = false
  }
  
  func pop() {
    linkedList.last?.value.weakReference?.isActive = false
  }
  
  func popToLast(customId: CustomViewID) {
    last (where: { $0.weakReference?.customId == customId })?.value.weakReference?.isActive = false
  }
}

#if DEBUG

extension NavigationStack {
  var isEmpty: Bool { linkedList.isEmpty }
  var count: Int { linkedList.count }
  func testCreateNavigationLink<Label, Destination>(viewModel: CustomNavigationLinkViewModel<CustomViewID>,
                                                    @ViewBuilder destination: @escaping () -> Destination,
                                                    @ViewBuilder label: @escaping () -> Label)
  -> CustomNavigationLink<Label, Destination, CustomViewID> where Label: View, Destination: View {
    .init(viewModel: viewModel, destination: destination, label: label)
  }
  
}
#endif

Usage (short sample)

Create NavigationLink:

struct Page: View {
    @EnvironmentObject var navigationStack: NavigationStack<String>
    var body: some View {
        navigationStack.navigationLink {
            NextView(...)
        } label: {
            Text("Next page")
        }
    }
}

Pop functionality

struct Page: View {
    @EnvironmentObject var navigationStack: NavigationStack<String>
    var body: some View {
        Button("Pop") {
            navigationStack.pop()
        }
        Button("Pop to Page 1") {
            navigationStack.popToLast(customId: "1")
        }
        Button("Pop to root") {
            navigationStack.popToRoot()
        }
    }
}

Usage (full sample)

import SwiftUI

struct ContentView: View {
  var body: some View {
    TabView {
      addTab(title: "Tab 1", systemImageName: "house")
      addTab(title: "Tab 2", systemImageName: "bookmark")
    }
  }
  
  func addTab(title: String, systemImageName: String) -> some View {
    NavigationView {
      RootPage(title: "\(title) home")
        .navigationBarTitle(title)
    }
    .environmentObject(NavigationStack<String>())
    .navigationViewStyle(StackNavigationViewStyle())
    .tabItem {
      Image(systemName: systemImageName)
      Text(title)
    }
  }
}

struct RootPage: View {
  let title: String
  var body: some View {
    SimplePage(title: title, pageCount: 0)
  }
}

struct SimplePage: View {
  @EnvironmentObject var navigationStack: NavigationStack<String>

  var title: String
  var pageCount: Int
  var body: some View {
    VStack {
      navigationStack.navigationLink(customId: "\(pageCount)") {
     // router.navigationLink {
        SimplePage(title: "Page: \(pageCount + 1)", pageCount: pageCount + 1)
      } label: {
        Text("Next page")
      }
      Button("Pop") {
        navigationStack.pop()
      }
      Button("Pop to Page 1") {
        navigationStack.popToLast(customId: "1")
      }
      Button("Pop to root") {
        navigationStack.popToRoot()
      }
    }
    .navigationTitle(title)
  }
}

Some Unit tests

@testable import SwiftUIPop
import XCTest
import SwiftUI
import Combine

class SwiftUIPopTests: XCTestCase {
  typealias CustomLinkID = String
  typealias Stack = NavigationStack<CustomLinkID>
  private let stack = Stack()
}

// MARK: Empty Navigation Stack

extension SwiftUIPopTests {
  func testNoCrashOnPopToRootOnEmptyStack() {
    XCTAssertTrue(stack.isEmpty)
    stack.popToRoot()
  }
  
  func testNoCrashOnPopToLastOnEmptyStack() {
    XCTAssertTrue(stack.isEmpty)
    stack.popToLast(customId: "123")
  }
  
  func testNoCrashOnPopOnEmptyStack() {
    XCTAssertTrue(stack.isEmpty)
    stack.pop()
  }
}

// MARK: expectation functions

private extension SwiftUIPopTests {
  func navigationStackShould(beEmpty: Bool) {
    if beEmpty {
      XCTAssertTrue(stack.isEmpty, "Navigation Stack should be empty")
    } else {
      XCTAssertFalse(stack.isEmpty, "Navigation Stack should not be empty")
    }
  }
}

// MARK: Data / model generators

private extension SwiftUIPopTests {
  func createNavigationLink(viewModel: CustomNavigationLinkViewModel<CustomLinkID>, stack: Stack)
  -> CustomNavigationLink<EmptyView, EmptyView, CustomLinkID> {
    stack.testCreateNavigationLink(viewModel: viewModel) {
      EmptyView()
    } label: {
      EmptyView()
    }
  }
  
  func createNavigationLinkViewModel(customId: CustomLinkID? = nil) -> CustomNavigationLinkViewModel<CustomLinkID> {
    .init(navigationStack: stack, customId: customId)
  }
}

// MARK: test `isActive` changing from `true` to `false` on `pop`

extension SwiftUIPopTests {
  private func isActiveChangeOnPop(customId: String? = nil,
                                   popAction: (Stack) -> Void,
                                   file: StaticString = #file,
                                   line: UInt = #line) {
    navigationStackShould(beEmpty: true)
    let expec = expectation(description: "Wait for viewModel.isActive changing")
    
    var canalables = Set<AnyCancellable>()
    let viewModel = createNavigationLinkViewModel(customId: customId)
    let navigationLink = createNavigationLink(viewModel: viewModel, stack: stack)
    navigationLink.viewModel.isActive = true
    navigationLink.viewModel.$isActive.dropFirst().sink { value in
      expec.fulfill()
    }.store(in: &canalables)
    
    navigationStackShould(beEmpty: false)
    popAction(stack)
    waitForExpectations(timeout: 2)
    navigationStackShould(beEmpty: true)
  }
  
  func testIsActiveChangeOnPop() {
    isActiveChangeOnPop { $0.pop() }
  }
  
  func testIsActiveChangeOnPopToRoot() {
    isActiveChangeOnPop { $0.popToRoot() }
  }
  
  func testIsActiveChangeOnPopToLast() {
    let customId = "1234"
    isActiveChangeOnPop(customId: customId) { $0.popToLast(customId: customId) }
  }
  
  func testIsActiveChangeOnPopToLast2() {
    navigationStackShould(beEmpty: true)
    let expec = expectation(description: "Wait")

    var canalables = Set<AnyCancellable>()
    let viewModel = createNavigationLinkViewModel(customId: "123")
    let navigationLink = createNavigationLink(viewModel: viewModel, stack: stack)
    navigationLink.viewModel.isActive = true
    navigationLink.viewModel.$isActive.dropFirst().sink { value in
      expec.fulfill()
    }.store(in: &canalables)

    navigationStackShould(beEmpty: false)
    stack.popToLast(customId: "1234")
    DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1)) {
      expec.fulfill()
    }
    waitForExpectations(timeout: 3)
    navigationStackShould(beEmpty: false)
  }
}

// MARK: Check that changing `CustomNavigationLinkViewModel.isActive` will update `Navigation Stack`

extension SwiftUIPopTests {

  // Add and remove view to the empty stack
  private func isActiveChangeUpdatesNavigationStack1(createLink: (Stack) -> CustomNavigationLink<EmptyView, EmptyView, String>) {
    navigationStackShould(beEmpty: true)
    let navigationLink = createLink(stack)
    navigationStackShould(beEmpty: true)
    navigationLink.viewModel.isActive = true
    navigationStackShould(beEmpty: false)
    navigationLink.viewModel.isActive = false
    navigationStackShould(beEmpty: true)
  }

  func testIsActiveChangeUpdatesNavigationStack1() {
    isActiveChangeUpdatesNavigationStack1 { stack in
      let viewModel = createNavigationLinkViewModel()
      return createNavigationLink(viewModel: viewModel, stack: stack)
    }
  }

  func testIsActiveChangeUpdatesNavigationStack2() {
    isActiveChangeUpdatesNavigationStack1 { stack in
      let viewModel = createNavigationLinkViewModel(customId: "123")
      return createNavigationLink(viewModel: viewModel, stack: stack)
    }
  }

  // Add and remove view to the non-empty stack
  private func isActiveChangeUpdatesNavigationStack2(createLink: (Stack) -> CustomNavigationLink<EmptyView, EmptyView, String>) {
    navigationStackShould(beEmpty: true)
    let viewModel1 = createNavigationLinkViewModel()
    let navigationLink1 = createNavigationLink(viewModel: viewModel1, stack: stack)
    navigationLink1.viewModel.isActive = true
    navigationStackShould(beEmpty: false)
    XCTAssertEqual(stack.count, 1, "Navigation Stack Should contains only one link")

    let navigationLink2 = createLink(stack)
    navigationLink2.viewModel.isActive = true
    navigationStackShould(beEmpty: false)
    navigationLink2.viewModel.isActive = false
    XCTAssertEqual(stack.count, 1, "Navigation Stack Should contains only one link")
  }

  func testIsActiveChangeUpdatesNavigationStack3() {
    isActiveChangeUpdatesNavigationStack2 { stack in
      let viewModel = createNavigationLinkViewModel()
      return createNavigationLink(viewModel: viewModel, stack: stack)
    }
  }

  func testIsActiveChangeUpdatesNavigationStack4() {
    isActiveChangeUpdatesNavigationStack2 { stack in
      let viewModel = createNavigationLinkViewModel(customId: "123")
      return createNavigationLink(viewModel: viewModel, stack: stack)
    }
  }
}
Vasily Bodnarchuk
  • 24,482
  • 9
  • 132
  • 127
1

malhal's answer is definitely the proper one. I made a wrapper to NavigationLink that allows me to apply any modifiers I need besides the isDetailLink(false) one and capture whatever data I need.

Specifically, it captures the isActive binding or the tag binding so that I can reset those when I want to pop to whatever view declared itself the root.

Setting isRoot = true will store the binding for that view, and the dismiss parameter takes an optional closure in case you need something done when the pop happens.

I copied the basic signatures from the SwiftUI NavigationLinks initializers for simple boolean or tag based navigation so that it is easy to edit existing usages. It should be straightforward to add others if needed.

The wrapper looks like this:

struct NavigationStackLink<Label, Destination> : View where Label : View, Destination : View {
    var isActive: Binding<Bool>? // Optionality implies whether tag or Bool binding is used
    var isRoot: Bool = false
    let link: NavigationLink<Label, Destination>

    private var dismisser: () -> Void = {}

    /// Wraps [NavigationLink](https://developer.apple.com/documentation/swiftui/navigationlink/init(isactive:destination:label:))
    /// `init(isActive: Binding<Bool>, destination: () -> Destination, label: () -> Label)`
    /// - Parameters:
    ///     - isActive:  A Boolean binding controlling the presentation state of the destination
    ///     - isRoot: Indicate if this is the root view. Used to pop to root level. Default `false`
    ///     - dismiss: A closure that is called when the link destination is about to be dismissed
    ///     - destination: The link destination view
    ///     - label: The links label
    init(isActive: Binding<Bool>, isRoot : Bool = false, dismiss: @escaping () -> Void = {}, @ViewBuilder destination: @escaping () -> Destination, @ViewBuilder label: @escaping () -> Label) {
        self.isActive = isActive
        self.isRoot = isRoot
        self.link = NavigationLink(isActive: isActive, destination: destination, label: label)
        self.dismisser = dismiss
    }

    /// Wraps [NavigationLink ](https://developer.apple.com/documentation/swiftui/navigationlink/init(tag:selection:destination:label:))
    init<V>(tag: V, selection: Binding<V?>, isRoot : Bool = false, dismiss: @escaping () -> Void = {}, @ViewBuilder destination: @escaping () -> Destination, @ViewBuilder label: @escaping () -> Label) where V : Hashable
    {
        self.isRoot = isRoot
        self.link = NavigationLink(tag: tag, selection: selection, destination: destination, label: label)
        self.dismisser = dismiss
        self.isActive = Binding (get: {
            selection.wrappedValue == tag
        }, set: { newValue in
            if newValue {
              selection.wrappedValue = tag
            } else {
              selection.wrappedValue = nil
            }
        })
    }

    // Make sure you inject your external store into your view hierarchy
    @EnvironmentObject var viewRouter: ViewRouter
    var body: some View {
        // Store whatever you need to in your external object
        if isRoot {
            viewRouter.root = isActive
        }
        viewRouter.dismissals.append(self.dismisser)
        // Return the link with whatever modification you need
        return link
            .isDetailLink(false)
    }
}

The ViewRouter can be whatever you need. I used an ObservableObject with the intent to eventually add some Published values for more complex stack manipulation in the future:

class ViewRouter: ObservableObject {

    var root: Binding<Bool>?
    typealias Dismiss = () -> Void
    var dismissals : [Dismiss] = []

    func popToRoot() {
        dismissals.forEach { dismiss in
            dismiss()
        }
        dismissals = []
        root?.wrappedValue = false
    }
}
Peter Mortensen
  • 30,738
  • 21
  • 105
  • 131
Jensie
  • 611
  • 5
  • 9
1

I did not find a solution in SwiftUI yet, but I found the library CleanUI.

Using the CUNavigation class, I can achieve exactly the navigation pattern I wanted.

An example from the library's README:

NavigationView {
    Button(action: {
      CUNavigation.pushToSwiftUiView(YOUR_VIEW_HERE)
    }){
      Text("Push To SwiftUI View")
    }

    Button(action: {
      CUNavigation.popToRootView()
    }){
      Text("Pop to the Root View")
    }

    Button(action: {
      CUNavigation.pushBottomSheet(YOUR_VIEW_HERE)
    }){
      Text("Push to a Botton-Sheet")
    }
}
Peter Mortensen
  • 30,738
  • 21
  • 105
  • 131
LN-12
  • 792
  • 8
  • 19
1

It's very hard to achieve with NavigationView and NavigationLink. However, if you are using the UIPilot library, which a tiny wrapper around NavigationView, popping to any destination is very straightforward.

Suppose you have routes,

enum AppRoute: Equatable {
    case Home
    case Detail
    case NestedDetail
}

and you have setup root view like below

struct ContentView: View {
    @StateObject var pilot = UIPilot(initial: AppRoute.Home)

    var body: some View {
        UIPilotHost(pilot)  { route in
            switch route {
                case .Home: return AnyView(HomeView())
                case .Detail: return AnyView(DetailView())
                case .NestedDetail: return AnyView(NestedDetail())
            }
        }
    }
}

And you want to pop to Home from the NestedDetail screen. Just use the popTo function.

struct NestedDetail: View {
    @EnvironmentObject var pilot: UIPilot<AppRoute>

    var body: some View {
        VStack {
            Button("Go to home", action: {
                pilot.popTo(.Home)   // Pop to home
            })
        }.navigationTitle("Nested detail")
    }
}
Peter Mortensen
  • 30,738
  • 21
  • 105
  • 131
jimmy0251
  • 16,293
  • 10
  • 36
  • 39
0

Elementary. Enough in the root view (where you want to go back) use NavigationLink with an isActive designer. In the last view, switch to the FALSE variable controlling the isActive parameter.

In the Swift version 5.5 use .isDetaillink(false) is optional.

You can use some common class as I have in the example, or transmit this variable down the VIEW hierarchy through binding. Use how it is more convenient for you.

class ViewModel: ObservableObject {
    @Published var isActivate = false
}

@main
struct TestPopToRootApp: App {
    let vm = ViewModel()
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(vm)
        }
    }
}

struct ContentView: View {
    @EnvironmentObject var vm: ViewModel
    
    var body: some View {
        NavigationView {
            NavigationLink("Go to view2", destination: NavView2(), isActive: $vm.isActivate)
            .navigationTitle(Text("Root view"))
        }
    }
}

struct NavView2: View {
    var body: some View {
        NavigationLink("Go to view3", destination: NavView3())
        .navigationTitle(Text("view2"))
    }
}

struct NavView3: View {
    @EnvironmentObject var vm: ViewModel
    
    var body: some View {
        Button {
            vm.isActivate = false
        } label: {
            Text("Back to root")
        }

        .navigationTitle(Text("view3"))
    }
}
Sergei Volkov
  • 818
  • 8
  • 15
0

To go to Root View without using .isDetailLink(false) you need to remove NavigationLink from hierarchy view of Root View

class NavigationLinkStore: ObservableObject {
    static let shared = NavigationLinkStore()

    @Published var showLink = false
}

struct NavigationLinkView: View {
    @ObservedObject var store = NavigationLinkStore.shared
    @State var isActive = false

    var body: some View {
        NavigationView {
            VStack {
                Text("Main")

                Button("Go to View1") {
                    Task {
                        store.showLink = true
                        try await Task.sleep(seconds: 0.1)
                        isActive = true
                    }
                }

                if store.showLink {
                    NavigationLink(
                        isActive: $isActive,
                        destination: { NavigationLink1View() },
                        label: { EmptyView() }
                    )
                }
            }
        }
    }
}

struct NavigationLink1View: View {
    var body: some View {
        VStack {
            Text("View1")
            NavigationLink("Go to View 2", destination: NavigationLink2View())
        }
    }
}

struct NavigationLink2View: View {
    @ObservedObject var store = NavigationLinkStore.shared

    var body: some View {
        VStack {
            Text("View2")
            Button("Go to root") {
                store.showLink = false
            }
        }
    }
}
Victor Kushnerov
  • 3,706
  • 27
  • 56
0

I created a solution that "just works" and am very happy with it. To use my magic solutions, there are only a few steps you have to do.

It starts out with using rootPresentationMode that's used elsewhere in this thread. Add this code:

// Create a custom environment key
struct RootPresentationModeKey: EnvironmentKey {
    static let defaultValue: Binding<RootPresentationMode> = .constant(RootPresentationMode())
}

extension EnvironmentValues {
    var rootPresentationMode: Binding<RootPresentationMode> {
        get { self[RootPresentationModeKey.self] }
        set { self[RootPresentationModeKey.self] = newValue }
    }
}

typealias RootPresentationMode = Bool

extension RootPresentationMode: Equatable {
    mutating func dismiss() {
        toggle()
    }
}

Next comes the magic. It has two steps.

  1. Create a view modifier that monitors changes to the rootPresentationMode variable.

    struct WithRoot: ViewModifier {
        @Environment(\.rootPresentationMode) private var rootPresentationMode
        @Binding var rootBinding: Bool
    
        func body(content: Content) -> some View {
            content
                .onChange(of: rootBinding) { newValue in
                    // We only care if it's set to true
                    if newValue {
                        rootPresentationMode.wrappedValue = true
                    }
                }
                .onChange(of: rootPresentationMode.wrappedValue) { newValue in
                    // We only care if it's set to false
                    if !newValue {
                        rootBinding = false
                    }
                }
        }
    }
    
    extension View {
        func withRoot(rootBinding: Binding<Bool>) -> some View {
            modifier(WithRoot(rootBinding: rootBinding))
        }
    }
    
  2. Add an isPresented to all NavigationViews

    struct ContentView: View {
        // This seems.. unimportant, but it's crucial. This variable
        // lets us pop back to the root view from anywhere by adding
        // a withRoot() modifier
        // It's only used indirectly by the withRoot() modifier.
        @State private var isPresented = false
    
        var body: some View {
            NavigationView {
                MyMoneyMakingApp()
            }
            // rootPresentationMode MUST be set on a NavigationView to be
            // accessible from everywhere
            .environment(\.rootPresentationMode, $isPresented)
        }
    

To use it in (any) subviews, all you have to do is

struct MyMoneyMakingApp: View {
    @State private var isActive = false

    var body: some View {
        VStack {
            NavigationLink(destination: ADeepDeepLink(), isActive: $isActive) {
                Text("go deep")
            }
        }
        .withRoot(rootBinding: $isActive)
    }
}

struct ADeepDeepLink: View {
    @Environment(\.rootPresentationMode) private var rootPresentationMode

    var body: some View {
        VStack {
            NavigationLink(destination: ADeepDeepLink()) {
                Text("go deeper")
            }
            Button(action: {
                rootPresentationMode.wrappedValue.dismiss()
            }) {
                Text("pop to root")
            }
        }
    }
}
Peter Mortensen
  • 30,738
  • 21
  • 105
  • 131
Bjørn Olav Jalborg
  • 373
  • 1
  • 3
  • 14
0

The answer from @malhal really helped out, but in my situation I needed functionality when each button was pressed before navigating. If you are in that same boat try this code out!

//  ContentView.swift
//  Navigation View Buttons
//
//  Created by Jarren Campos on 9/10/22.
//

import SwiftUI

struct ContentView: View {

    var body: some View{
        VStack{
            ContentView1()
        }
    }
}

struct ContentView1: View {
    @State var isActive : Bool = false

    var body: some View {
        NavigationView {
            VStack{
                Button {
                    isActive = true
                } label: {
                    Text("To 2")
                }
            }
            .background{
                NavigationLink(
                    destination: ContentView2(rootIsActive: self.$isActive),
                    isActive: self.$isActive) {}
                    .isDetailLink(false)
            }
            .navigationBarTitle("One")
        }
    }
}

struct ContentView2: View {
    @Binding var rootIsActive : Bool
    @State var toThirdView: Bool = false

    var body: some View {

        VStack{
            Button {
                toThirdView = true
            } label: {
                Text("to 3")
            }
        }
        .background{
            NavigationLink(isActive: $toThirdView) {
                ContentView3(shouldPopToRootView: self.$rootIsActive)
            } label: {}
                .isDetailLink(false)
        }
        .navigationBarTitle("Two")

    }
}

struct ContentView3: View {
    @Binding var shouldPopToRootView : Bool

    var body: some View {
        VStack {
            Text("Hello, World #3!")
            Button {
                self.shouldPopToRootView = false
            } label: {
                Text("Pop to root")
            }
        }
        .navigationBarTitle("Three")
    }
}
JC Dev
  • 16
  • 1
0

For me, this worked

  guard let firstScene = UIApplication.shared.connectedScenes.first as? UIWindowScene else {
                    return
                }
                
                guard let firstWindow = firstScene.windows.first else {
                    return
                }
                firstWindow.rootViewController = UIHostingController(rootView: YourSwiftUIRootView())
                firstWindow.makeKeyAndVisible()
mikail yusuf
  • 197
  • 3
  • 5
-1

I don't have exactly the same issue but I do have code that changes the root view from one that doesn't support a navigation stack to one that does. The trick is that I don't do it in SwiftUI - I do it in the SceneDelegate and replace the UIHostingController with a new one.

Here's a simplified extract from my SceneDelegate:

    func changeRootToOnBoarding() {
        guard let window = window else {
            return
        }

        let onBoarding = OnBoarding(coordinator: notificationCoordinator)
            .environmentObject(self)

        window.rootViewController = UIHostingController(rootView: onBoarding)
    }

    func changeRootToTimerList() {
        guard let window = window else {
            return
        }

        let listView = TimerList()
            .environmentObject(self)
        window.rootViewController = UIHostingController(rootView: listView)
    }

Since the SceneDelegate put itself in the environment any child view can add

    /// Our "parent" SceneDelegate that can change the root view.
    @EnvironmentObject private var sceneDelegate: SceneDelegate

and then call public functions on the delegate. I think if you did something similar that kept the View but created a new UIHostingController for it and replaced window.rootViewController it might work for you.

Timothy Sanders
  • 206
  • 3
  • 7
  • This is an interesting idea, but it seems like a very drastic approach considering the relatively simple goal. Especially if the Navigation stack in question is just one tab in a TabView. I'm really hoping that Apple will roll out more Navigation support for SwiftUI in the near future. – Chuck H Aug 24 '19 at 19:22
  • Oh yeah, it's definitely a hack, I'm not in love with everybody having to get ahold of the `SceneDelegate` either. It *works* if you need a "right now" sort of solution. – Timothy Sanders Aug 26 '19 at 22:33
  • I did something similar: https://stackoverflow.com/questions/57711277/completely-move-to-other-view-and-dont-allow-to-go-back-in-swiftui/57717462#57717462 – Sajjon Aug 30 '19 at 15:03
-2

It is easier to present and dismiss a modal view controller that includes a NavigationView. Setting the modal view controller to fullscreen and later dismissing it gives the same effect as a stack of navigation views that pop to root.

See e.g. How to present a full screen modal view using fullScreenCover().

Peter Mortensen
  • 30,738
  • 21
  • 105
  • 131