25

Is there any way to pop to root view by tapping the Tab Bar like most iOS apps, in SwiftUI?

Here's an example of the expected behavior.

enter image description here

I've tried to programmatically pop views using simultaneousGesture as follow:

import SwiftUI


struct TabbedView: View {
    @State var selection = 0
    @Environment(\.presentationMode) var presentationMode

    var body: some View {
        TabView(selection: $selection) {
            RootView()
                .tabItem {
                    Image(systemName: "house")
                        .simultaneousGesture(
                            TapGesture().onEnded {
                                self.presentationMode.wrappedValue.dismiss()
                                print("View popped")
                            }
                        )
                }.tag(0)
                
            Text("")
                .tabItem {
                    Image(systemName: "line.horizontal.3")
                }.tag(1)
        }
    }
}

struct RootView: View {
    var body: some View {
        NavigationView {
            NavigationLink(destination: SecondView()) {
                Text("Go to second view")
            }
        }
    }
}

struct SecondView: View {
    var body: some View {
        Text("Tapping the house icon should pop back to root view")
    }
}

But seems like those gestures were ignored.

Any suggestions or solutions are greatly appreciated

2
  • Am I understanding right, that this default iOS functionality doesn't work from the box on SwiftUI 1.0/2.0/3.0? Commented Apr 28, 2022 at 8:41
  • 1
    @surfrider Your understanding is correct. SwiftUI 1.0/2.0/3.0 by default doesn't have this functionality. Commented Apr 28, 2022 at 13:07

7 Answers 7

10

We can use tab bar selection binding to get the selected index. On this binding we can check if the tab is already selected then pop to root for navigation on selection.

struct ContentView: View {

@State var showingDetail = false
@State var selectedIndex:Int = 0

var selectionBinding: Binding<Int> { Binding(
    get: {
        self.selectedIndex
    },
    set: {
        if $0 == self.selectedIndex && $0 == 0 && showingDetail {
            print("Pop to root view for first tab!!")
            showingDetail = false
        }
        self.selectedIndex = $0
    }
)}

var body: some View {
    
    TabView(selection:selectionBinding) {
        NavigationView {
            VStack {
                Text("First View")
                NavigationLink(destination: DetailView(), isActive: $showingDetail) {
                    Text("Go to detail")
                }
            }
        }
        .tabItem { Text("First") }.tag(0)
        
        Text("Second View")
            .tabItem { Text("Second") }.tag(1)
    }
  }
}

struct DetailView: View {
 var body: some View {
    Text("Detail")
  }
}
Sign up to request clarification or add additional context in comments.

6 Comments

Thank you! I've waited 2 years for this! You'll have to create a unique isActive boolean for each NavigationLink, which can be quite annoying sometimes though.
You don't have to put isActive for each NavigationLink, this only isActive flag will help you to navigate to root.
But I added a second NavigationLink that navigates to a new view (Detail 2), and the two NavigationLink randomly navigates to Detail or Detail 2 when using the same isActive. Example
Oh, I understand it, that could make a little mess. But I think it could be a suitable solution until the default support for this feature.
still can't see solution "how pop to root" in your answer :(
|
5

I messed around with this for a while and this works great. I combined answers from all over and added some stuff of my own. I'm a beginner at Swift so feel free to make improvements.

Here's a demo.

enter image description here

This view has the NavigationView.

import SwiftUI

struct AuthenticatedView: View {
    
    @StateObject var tabState = TabState()
            
    var body: some View {
        TabView(selection: $tabState.selectedTab) {
            NavigationView {
                NavigationLink(destination: TestView(titleNum: 0), isActive: $tabState.showTabRoots[0]) {
                    Text("GOTO TestView #1")
                        .padding()
                        .foregroundColor(Color.white)
                        .frame(height:50)
                        .background(Color.purple)
                        .cornerRadius(8)
                }
                .navigationTitle("")
                .navigationBarTitleDisplayMode(.inline)
            }
            .navigationViewStyle(.stack)
            .onAppear(perform: {
                tabState.lastSelectedTab = TabState.Tab.first
            }).tabItem {
                Label("First", systemImage: "list.dash")
            }.tag(TabState.Tab.first)
            
            NavigationView {
                NavigationLink(destination: TestView(titleNum: 0), isActive: $tabState.showTabRoots[1]) {
                    Text("GOTO TestView #2")
                        .padding()
                        .foregroundColor(Color.white)
                        .frame(height:50)
                        .background(Color.purple)
                        .cornerRadius(8)
                }.navigationTitle("")
                    .navigationBarTitleDisplayMode(.inline).navigationBarTitle(Text(""), displayMode: .inline)
            }
            .navigationViewStyle(.stack)
            .onAppear(perform: {
                tabState.lastSelectedTab = TabState.Tab.second
            }).tabItem {
                Label("Second", systemImage: "square.and.pencil")
            }.tag(TabState.Tab.second)
        }
        .onReceive(tabState.$selectedTab) { selection in
            if selection == tabState.lastSelectedTab {
                tabState.showTabRoots[selection.rawValue] = false
            }
        }
    }
}

struct AuthenticatedView_Previews: PreviewProvider {
    static var previews: some View {
        AuthenticatedView()
    }
}

class TabState: ObservableObject {
    enum Tab: Int, CaseIterable {
        case first = 0
        case second = 1
    }
        
    @Published var selectedTab: Tab = .first
    @Published var lastSelectedTab: Tab = .first
    
    @Published var showTabRoots = Tab.allCases.map { _ in
        false
    }
}

This is my child view

import SwiftUI

struct TestView: View {
    
    let titleNum: Int
    let title: String
    
    init(titleNum: Int) {
        self.titleNum = titleNum
        self.title = "TestView #\(titleNum)"
    }
       
    var body: some View {
        VStack {
            Text(title)
            NavigationLink(destination: TestView(titleNum: titleNum + 1)) {
                Text("Goto View #\(titleNum + 1)")
                    .padding()
                    .foregroundColor(Color.white)
                    .frame(height:50)
                    .background(Color.purple)
                    .cornerRadius(8)
            }
            NavigationLink(destination: TestView(titleNum: titleNum + 100)) {
                Text("Goto View #\(titleNum + 100)")
                    .padding()
                    .foregroundColor(Color.white)
                    .frame(height:50)
                    .background(Color.purple)
                    .cornerRadius(8)
            }
            .navigationTitle(title)
            .navigationBarTitleDisplayMode(.inline)
        }
    }
}

struct TestView_Previews: PreviewProvider {
    static var previews: some View {
        TestView(titleNum: 0)
    }
}

3 Comments

Nice solution. In addition you could also add TabState as an environment object to allow each tab to be a separate view (and avoid massive view hierarchy in the TabView itself).
@Philio I'll have to look in to that because I noticed while I was messing with this example, my memory usage would just continue growing as I would navigate. It was like I had a memory leak somewhere. I think your suggestion is the missing piece to the puzzle. Thanks!
@Bropane do you have usable example using it as environment variable.
5

You could do this with the help of UIKit by finding currently visible navigation controller for example as in the code below and calling popToRootViewController on it when user taps already selected tab. This works on iOS 15+:

struct TabbedView: View {
    @State private var selection = 0
    private var selectionBinding: Binding<Int> {
        Binding(get: {
            selection
        }, set: {
            if $0 == selection {
                let window = (UIApplication.shared.connectedScenes.first as? UIWindowScene)?.windows.first
                let navigationController = window?.rootViewController?.recursiveChildren().first(where: { $0 is UINavigationController && $0.view.window != nil }) as? UINavigationController
                navigationController?.popToRootViewController(animated: true)
            }
            selection = $0
        })
    }
    var body: some View {
        TabView(selection: selectionBinding) {
            RootView(name: "first")
                .tabItem {
                    Image(systemName: "house")
                }.tag(0)
            RootView(name: "second")
                .tabItem {
                    Image(systemName: "table")
                }.tag(1)
        }
    }
}
struct RootView: View {
    var name: String
    var body: some View {
        NavigationView {
            NavigationLink(destination: SecondView(name: name)) {
                Text("Go to second view (\(name))")
            }
        }
    }
}
struct SecondView: View {
    var name: String
    var body: some View {
        Text("Tap current tab to pop back to root view (\(name))")
    }
}
extension UIViewController {
    func recursiveChildren() -> [UIViewController] {
        return children + children.flatMap({ $0.recursiveChildren() })
    }
}

Comments

3

In my own app I use the approach of changing the .id modifier on the topmost view which causes the NavigationView to pop to root. For clarity I also factored out this nav-popping behavior into its own wrapper view, "TabbedNavView", as shown below.

import SwiftUI

class TabMonitor: ObservableObject {
    @Published var selectedTab = 1
}

struct ContentView: View {
    @StateObject private var tabMonitor = TabMonitor()
    
    var body: some View {
        TabView(selection: $tabMonitor.selectedTab) {
            TabbedNavView(tag: 1) {
                DetailView(index: 1)
            }
            .tabItem { Label("Tab1", systemImage: "book") }
            .tag(1)
            
            TabbedNavView(tag: 2) {
                DetailView(index: 10)
            }
            .tabItem { Label("Tab2", systemImage: "wrench") }
            .tag(2)
        } //TabView
        .environmentObject(tabMonitor)
    } //body
} //ContentView


struct DetailView: View {
    var index: Int
    
    var body: some View {
        NavigationLink(
            destination: DetailView(index: index + 1)
        ) {
            Text("Detail \(index)")
        }
    } //body
} //DetailView

struct TabbedNavView: View {
    @EnvironmentObject var tabMonitor: TabMonitor
    
    private var tag: Int
    private var content: AnyView

    init(
        tag: Int,
        @ViewBuilder _ content: () -> any View
    ) {
        self.tag = tag
        self.content = AnyView(content())
    } //init(tag:content:)

    @State private var id = 1
    @State private var selected = false

    var body: some View {
        NavigationView {
            content
                .id(id)
                .onReceive(tabMonitor.$selectedTab) { selection in
                    if selection != tag {
                        selected = false
                    } else {
                        if selected {
                            id *= -1 //id change causes pop to root
                        }

                        selected = true
                    }
                } //.onReceive
        } //NavigationView
        .navigationViewStyle(.stack)
    } //body
} //TabbedNavView

The nice thing about this approach is that it works with any number of tabs, and any number of NavigationLinks within each tab. Each TabbedNavView keeps track of whether itself is the selected tab, and pops the NavigationView to root (by flipping the sign of the root view's .id modifier) whenever a tab is tapped twice in a row.

Edit: works with iOS 15+.

2 Comments

I used an approach like this for my app too. I think this also works for iOS 14. Basically, we force the topmost view to refresh to pop to root. I've found this workaround to be the simplest solution, even for SwiftUI 4.0 / iOS 16.
It also causes the root view it "pops" to, to re-draw/reload as if being shown for the first time, adds a massive layer of adjusting code to take that into account. Especially for loading content on first appear.
2

This is a very simple solution that works for me.

I use a a state variable goHome and assign a UUID() to it. Using onTapGesture with a count of 2 assigns a new UUID() to goHome which resets the TabView to its initial state.

Double tapping anywhere on any screen in the 'stack' resets tabview and takes you back to the tabview 'root'.

import SwiftUI

struct MainView: View {
    @State private var tabSelection: Int = 1
    @State private var goHome = UUID()
    
    var body: some View {
            DocumentsView()
                .tabItem {
                    Image(systemName: "doc.plaintext.fill")
                    Text("Documents")
                }.tag(3)
            ToolsView()
                .tabItem {
                    Image(systemName: "wrench.and.screwdriver.fill")
                    Text("Tools")
                }.tag(4)
        }
        .id(goHome)
        .onTapGesture(count: 2, perform: {
            goHome = UUID()
        })
    }
}

1 Comment

This is lacking the pop animation unfortunately.
0

I would add animation to @StarRayder solution. It will simulate the default-like NavigationView's animation.

  1. Add this anywhere to the project:
    extension AnyTransition {
        static var customTransition: AnyTransition {
            let insertion = AnyTransition.move(edge: .leading)
            let removal = AnyTransition.move(edge: .trailing)
            return .asymmetric(insertion: insertion, removal: removal)
        }
    }
  1. After .id(goHome) add this line:
    .transition(.customTransition)

1 Comment

Mmmm... Is this working for anyone, does not work for me for some reason?
-1

You can achieve this by having the TabView within a NavigationView like so:

struct ContentView: View {
    @State var selection = 0
    var body: some View {
        NavigationView {
            TabView(selection: $selection) {
                FirstTabView()
                    .tabItem {
                        Label("Home", systemImage: "house")
                    }
                    .tag(0)
            }
        }
        .navigationViewStyle(StackNavigationViewStyle())
    }
}

struct FirstTabView: View {
    var body: some View {
        NavigationLink("SecondView Link", destination: SecondView())
    }
}

struct SecondView: View {
    var body: some View {
        Text("Second View")
    }
}

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

2 Comments

Thanks, but wouldn't having the TabView within the NavigationView causes the tab bar to move away when the first view moves away? Example
I would strongly recommend against ever putting a TabView inside a NavigationView (at least at the moment), since it will cause all kinds of terrible bugs that you won't be able to fix unless you put the TabView outside of the NavigationView

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.