Khắc Hào
Khắc Hào

Reputation: 2688

Pop to root view using Tab Bar in SwiftUI

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

Upvotes: 25

Views: 8874

Answers (7)

Leszek Szary
Leszek Szary

Reputation: 10346

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() })
    }
}

Upvotes: 5

andreybashta
andreybashta

Reputation: 1

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)

Upvotes: 0

protasm
protasm

Reputation: 1297

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+.

Upvotes: 3

StarRayder
StarRayder

Reputation: 21

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()
        })
    }
}

Upvotes: 2

Bropane
Bropane

Reputation: 134

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

Upvotes: 5

Usama Azam
Usama Azam

Reputation: 433

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

Upvotes: 10

Andrew Stoddart
Andrew Stoddart

Reputation: 516

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()
        }
    }
}

Upvotes: -1

Related Questions