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:

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:

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