'SwiftUI: Pop to root view when selected tab is tapped again

Starting point is a NavigationView within a TabView. I'm struggling with finding a SwiftUI solution to pop to the root view within the navigation stack when the selected tab is tapped again. In the pre-SwiftUI times, this was as simple as the following:

func tabBarController(_ tabBarController: UITabBarController, didSelect viewController: UIViewController) {
    let navController = viewController as! UINavigationController
    navController.popViewController(animated: true)
}

Do you know how the same thing can be achieved in SwiftUI?

Currently, I use the following workaround that relies on UIKit:

if let windowScene = scene as? UIWindowScene {
            let window = UIWindow(windowScene: windowScene)

            let navigationController = UINavigationController(rootViewController: UIHostingController(rootView:
                MyCustomView() // -> this is a normal SwiftUI file
                    .environment(\.managedObjectContext, context)
            ))
            navigationController.tabBarItem = UITabBarItem(title: "My View 1", image: nil, selectedImage: nil)

            // add more controllers that are part of tab bar controller

            let tabBarController = UITabBarController()
            tabBarController.viewControllers = [navigationController /*,  additional controllers */]

            window.rootViewController = tabBarController // UIHostingController(rootView: contentView)
            self.window = window
            window.makeKeyAndVisible()
        }


Solution 1:[1]

Here is possible approach. For TabView it gives the same behaviour as tapping to the another tab and back, so gives persistent look & feel.

Tested & works with Xcode 11.2 / iOS 13.2

demo

Full module code:

import SwiftUI

struct TestPopToRootInTab: View {
    @State private var selection = 0
    @State private var resetNavigationID = UUID()

    var body: some View {

        let selectable = Binding(        // << proxy binding to catch tab tap
            get: { self.selection },
            set: { self.selection = $0

                // set new ID to recreate NavigationView, so put it
                // in root state, same as is on change tab and back
                self.resetNavigationID = UUID()
        })

        return TabView(selection: selectable) {
            self.tab1()
                .tabItem {
                    Image(systemName: "1.circle")
                }.tag(0)
            self.tab2()
                .tabItem {
                    Image(systemName: "2.circle")
                }.tag(1)
        }
    }

    private func tab1() -> some View {
        NavigationView {
            NavigationLink(destination: TabChildView()) {
                Text("Tab1 - Initial")
            }
        }.id(self.resetNavigationID) // << making id modifiable
    }

    private func tab2() -> some View {
        Text("Tab2")
    }
}

struct TabChildView: View {
    var number = 1
    var body: some View {
        NavigationLink("Child \(number)",
            destination: TabChildView(number: number + 1))
    }
}

struct TestPopToRootInTab_Previews: PreviewProvider {
    static var previews: some View {
        TestPopToRootInTab()
    }
}

Solution 2:[2]

Here's an approach that uses a PassthroughSubject to notify the child view whenever the tab is re-selected, and a view modifier to allow you to attach .onReselect() to a view.

import SwiftUI
import Combine

enum TabSelection: String {
    case A, B, C // etc

}

private struct DidReselectTabKey: EnvironmentKey {
    static let defaultValue: AnyPublisher<TabSelection, Never> = Just(.Mood).eraseToAnyPublisher()
}

private struct CurrentTabSelection: EnvironmentKey {
    static let defaultValue: Binding<TabSelection> = .constant(.Mood)
}

private extension EnvironmentValues {
    var tabSelection: Binding<TabSelection> {
        get {
            return self[CurrentTabSelection.self]
        }
        set {
            self[CurrentTabSelection.self] = newValue
        }
    }

    var didReselectTab: AnyPublisher<TabSelection, Never> {
        get {
            return self[DidReselectTabKey.self]
        }
        set {
            self[DidReselectTabKey.self] = newValue
        }
    }
}

private struct ReselectTabViewModifier: ViewModifier {
    @Environment(\.didReselectTab) private var didReselectTab

    @State var isVisible = false
    
    let action: (() -> Void)?

    init(perform action: (() -> Void)? = nil) {
        self.action = action
    }
        
    func body(content: Content) -> some View {
        content
            .onAppear {
                self.isVisible = true
            }.onDisappear {
                self.isVisible = false
            }.onReceive(didReselectTab) { _ in
                if self.isVisible, let action = self.action {
                    action()
                }
            }
    }
}

extension View {
    public func onReselect(perform action: (() -> Void)? = nil) -> some View {
        return self.modifier(ReselectTabViewModifier(perform: action))
    }
}

struct NavigableTabViewItem<Content: View>: View {
    @Environment(\.didReselectTab) var didReselectTab

    let tabSelection: TabSelection
    let imageName: String
    let content: Content
    
    init(tabSelection: TabSelection, imageName: String, @ViewBuilder content: () -> Content) {
        self.tabSelection = tabSelection
        self.imageName = imageName
        self.content = content()
    }

    var body: some View {
        let didReselectThisTab = didReselectTab.filter( { $0 == tabSelection }).eraseToAnyPublisher()

        NavigationView {
            self.content
                .navigationBarTitle(tabSelection.localizedStringKey, displayMode: .inline)
        }.tabItem {
            Image(systemName: imageName)
            Text(tabSelection.localizedStringKey)
        }
        .tag(tabSelection)
        .navigationViewStyle(StackNavigationViewStyle())
        .keyboardShortcut(tabSelection.keyboardShortcut)
        .environment(\.didReselectTab, didReselectThisTab)
    }
}

struct NavigableTabView<Content: View>: View {
    @State private var didReselectTab = PassthroughSubject<TabSelection, Never>()
    @State private var _selection: TabSelection = .Mood

    let content: Content

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

    var body: some View {
        let selection = Binding(get: { self._selection },
                                set: {
                                    if self._selection == $0 {
                                        didReselectTab.send($0)
                                    }
                                    self._selection = $0
                                })

        TabView(selection: selection) {
            self.content
                .environment(\.tabSelection, selection)
                .environment(\.didReselectTab, didReselectTab.eraseToAnyPublisher())
        }
    }
}

Solution 3:[3]

Here's how I did it:

struct UIKitTabView: View {
    var viewControllers: [UIHostingController<AnyView>]

    init(_ tabs: [Tab]) {
        self.viewControllers = tabs.map {
            let host = UIHostingController(rootView: $0.view)
            host.tabBarItem = $0.barItem
            return host
        }
    }

    var body: some View {
        TabBarController(controllers: viewControllers).edgesIgnoringSafeArea(.all)
    }

    struct Tab {
        var view: AnyView
        var barItem: UITabBarItem

        init<V: View>(view: V, barItem: UITabBarItem) {
            self.view = AnyView(view)
            self.barItem = barItem
        }
    }
}


struct TabBarController: UIViewControllerRepresentable {
    var controllers: [UIViewController]

    func makeUIViewController(context: Context) -> UITabBarController {
        let tabBarController = UITabBarController()
        tabBarController.viewControllers = controllers
        tabBarController.delegate = context.coordinator
        return tabBarController
    }

    func updateUIViewController(_ uiViewController: UITabBarController, context: Context) { }
}

extension TabBarController {
    func makeCoordinator() -> TabBarController.Coordinator {
        Coordinator(self)
    }
    class Coordinator: NSObject, UITabBarControllerDelegate {
        var parent: TabBarController
        init(_ parent: TabBarController){self.parent = parent}
        var previousController: UIViewController?
        private var shouldSelectIndex = -1
        
        func tabBarController(_ tabBarController: UITabBarController, shouldSelect viewController: UIViewController) -> Bool {
            shouldSelectIndex = tabBarController.selectedIndex
            return true
        }

        func tabBarController(_ tabBarController: UITabBarController, didSelect viewController: UIViewController) {
            if shouldSelectIndex == tabBarController.selectedIndex {
                if let navVC = tabBarController.viewControllers![shouldSelectIndex].nearestNavigationController {
                    if (!(navVC.popViewController(animated: true) != nil)) {
                        navVC.viewControllers.first!.scrollToTop()
                    }
                }
            }
        }
    }
}

extension UIViewController {
    var nearestNavigationController: UINavigationController? {
        if let selfTypeCast = self as? UINavigationController {
            return selfTypeCast
        }
        if children.isEmpty {
            return nil
        }
        for child in self.children {
            return child.nearestNavigationController
        }
        return nil
    }
}

extension UIViewController {
    func scrollToTop() {
        func scrollToTop(view: UIView?) {
            guard let view = view else { return }
            switch view {
            case let scrollView as UIScrollView:
                if scrollView.scrollsToTop == true {
                    scrollView.setContentOffset(CGPoint(x: 0.0, y: -scrollView.safeAreaInsets.top), animated: true)
                    return
                }
            default:
                break
            }

            for subView in view.subviews {
                scrollToTop(view: subView)
            }
        }
        scrollToTop(view: view)
    }
}

Then in ContentView.swift I use it like this:

struct ContentView: View {
    var body: some View {
        ZStack{
            UIKitTabView([
                UIKitTabView.Tab(
                    view: FirstView().edgesIgnoringSafeArea(.top),
                    barItem: UITabBarItem(title: "Tab1", image: UIImage(systemName: "star"), selectedImage: UIImage(systemName: "star.fill"))
                ),
                UIKitTabView.Tab(
                    view: SecondView().edgesIgnoringSafeArea(.top),
                    barItem: UITabBarItem(title: "Tab2", image: UIImage(systemName: "star"), selectedImage: UIImage(systemName: "star.fill"))
                ),
            ])
            
        }
    }
}

Note that when the user is already on the root view, it scrolls to top automatically

Solution 4:[4]

Here's what I did with introspect swiftUI library. https://github.com/siteline/SwiftUI-Introspect

struct TabBar: View {
    @State var tabSelected: Int = 0
    @State var navBarOne: UINavigationController?
    @State var navBarTwo: UINavigationController?
    @State var navBarThree: UINavigationController?

    
    var body: some View {
        
       return  TabView(selection: $tabSelected){
           NavView(navigationView: $navBarOne).tabItem {
               Label("Home1",systemImage: "bag.fill")
            }.tag(0)
            
           NavView(navigationView: $navBarTwo).tabItem {
                Label("Orders",systemImage: "scroll.fill" )
            }.tag(1)
            
           NavView(navigationView: $navBarThree).tabItem {
                Label("Wallet", systemImage: "dollarsign.square.fill" )
               // Image(systemName: tabSelected == 2 ? "dollarsign.square.fill" : "dollarsign.square")
            }.tag(2)
    
        }.onTapGesture(count: 2) {
            switch tabSelected{
            case 0:
                self.navBarOne?.popToRootViewController(animated: true)
            case 1:
                self.navBarTwo?.popToRootViewController(animated: true)
            case 2:
                self.navBarThree?.popToRootViewController(animated: true)
            default:
                print("tapped")
            }
        }
    }
}

NavView:

import SwiftUI
import Introspect

struct NavView: View {
    
    @Binding var navigationView: UINavigationController?
    var body: some View {
        NavigationView{
            VStack{
                NavigationLink(destination: Text("Detail view")) {
                    Text("Go To detail")
                }
            }.introspectNavigationController { navController in
                navigationView = navController
            }
        }
    }
}

This actually isn't the best approach because it makes the entire tab view and everything inside of it have the double-tap gesture which would pop the view to its root. My current fix for this allows for one tap to pop up root view haven't figured out how to add double tap

struct TabBar: View {
    @State var tabSelected: Int = 0
    @State var navBarOne: UINavigationController?
    @State var navBarTwo: UINavigationController?
    @State var navBarThree: UINavigationController?
    
    @State var selectedIndex:Int = 0
    var selectionBinding: Binding<Int> { Binding(
        get: {
            self.selectedIndex
        },
        set: {
            if $0 == self.selectedIndex {
                popToRootView(tabSelected: $0)
            }
            self.selectedIndex = $0
        }
    )}


    var body: some View {

       return  TabView(selection: $tabSelected){
           NavView(navigationView: $navBarOne).tabItem {
               Label("Home1",systemImage: "bag.fill")
            }.tag(0)

           NavView(navigationView: $navBarTwo).tabItem {
                Label("Orders",systemImage: "scroll.fill" )
            }.tag(1)

           NavView(navigationView: $navBarThree).tabItem {
                Label("Wallet", systemImage: "dollarsign.square.fill" )
               // Image(systemName: tabSelected == 2 ? "dollarsign.square.fill" : "dollarsign.square")
            }.tag(2)

        }
    }
    
    func popToRootView(tabSelected: Int){
        switch tabSelected{
        case 0:
            self.navBarOne?.popToRootViewController(animated: true)
        case 1:
            self.navBarTwo?.popToRootViewController(animated: true)
        case 2:
            self.navBarThree?.popToRootViewController(animated: true)
        default:
            print("tapped")
        }
    }
}

Solution 5:[5]

I took an approach similar to Asperi Use a combination of a custom binding, and a separately stored app state var for keeping state of the navigation link.

The custom binding allows you to see all taps basically even when the current tab is the one thats tapped, something that onChange of tab selection binding doesn't show. This is what imitates the UIKit TabViewDelegate behavior.

This doesn't require a "double tap", if you just a single tap of the current, if you want double tap you'll need to implement your own tap/time tracking but shouldn't be too hard.

class AppState: ObservableObject {
  @Published var mainViewShowingDetailView = false
}

struct ContentView: View {
  @State var tabState: Int = 0
  @StateObject var appState = AppState()

  var body: some View {
    let binding = Binding<Int>(get: { tabState },
                   set: { newValue in 
      if newValue == tabState { // tapped same tab they're already on
        switch newValue {
        case 0: appState.mainViewShowingDetailView = false
        default: break
        }
      }
      tabState = newValue // make sure you actually set the storage
    })
    TabView(selection: binding) {
      MainView()  
        .tabItem({ Label("Home", systemImage: "list.dash") })
        .tag(0)
        .environmentObject(appState)
    }
  }
}

struct MainView: View {
  @EnvironmentObject var appState: AppState
  var body: {
    NavigationView {
      VStack {
        Text("Hello World")
        NavigationLink(destination: DetailView(),
                       isActive: $appState.mainViewShowingDetailView,
                       label: { Text("Show Detail") })
      }
    }
  }
}

struct DetailView: View {
  ...
}

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 Asperi
Solution 2
Solution 3 YT.Lu
Solution 4
Solution 5 apocolipse