burki
burki

Reputation: 2986

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

Upvotes: 33

Views: 12779

Answers (8)

Chris
Chris

Reputation: 4391

Apple have just publicly released iOS 18. I have just discovered in my own that double-tap to pop to the root view of a tab is now the default behaviour. This did not need the app to be recompiled in Xcode 16.

Upvotes: 0

parapote
parapote

Reputation: 974

The simplest, “SwiftUI-friendly“ solution is to create a history of selected tabs and insert it into the environment. This way, you can observe this environment variable in any view of your app through onChange.

First, create a Tab model.

enum Tab: Int {
    static let `default`: Tab = .tabA
    
    case tabA
    case tabB
    case tabC
}

Secondly, create a new environment key named tabs.

private struct TabsEnvironmentKey: EnvironmentKey {
    static let defaultValue: [Tab] = []
}

extension EnvironmentValues {
    var tabs: [Tab] {
        get { self[TabsEnvironmentKey.self] }
        set { self[TabsEnvironmentKey.self] = newValue }
    }
}

The last step is to create a Binding<Tab> returning the last selected tab, and updating the tab history each time a tab is tapped. Insert it into the environment with .environment(\.tabs, tabs).

struct ContentView: View {
    @State private var tabs: [Tab] = []
    
    var tab: Binding<Tab> {
        .init {
            tabs.last ?? .default
        } set: { newValue in
            tabs.append(newValue)
        }
    }

    var body: some View {
        TabView(selection: tab) {
            // …
        }
        .environment(\.tabs, tabs)
    }
}

From now on, any child view can access the tabs variable by calling @Environment(\.tabs) private var tabs and the latter can be observed using onChange.

struct TabAView: View {
    @Environment(\.tabs) private var tabs
    @State private var navigationPath = NavigationPath()

    var body: some View {
        NavigationStack(path: $navigationPath) {
            // …
        }
        .onChange(of: tabs, handleChange)
    }

    func handleChange(oldValue: [Tab], newValue: [Tab]) {
        guard let last = newValue.last else { return }
        guard let secondToLast = newValue.dropLast().last else { return }
        guard last == .tabA else { return }
        guard secondToLast == last else { return }
        if path.isEmpty {
            // do something else if you want
        } else {
            navigationPath.removeLast()
        }
    }
}

Upvotes: 0

Kent Robin
Kent Robin

Reputation: 2596

iOS 16 / NavigationStack approach with PassthroughSubject

Uses willSet on selectedTab to get the tap event, and uses a PassthroughSubject for sending the event to the children. This is picked up by the .onReceived and calls a function for popping the views from the NavigationStack

Did a full write up here: https://kentrobin.com/home/tap-tab-to-go-back/ and created a working demo project here: https://github.com/kentrh/demo-tap-tab-to-go-back

class HomeViewModel: ObservableObject {
    @Published var selectedTab: Tab = .tab1 {
        willSet {
            if selectedTab == newValue {
                subject.send(newValue)
            }
        }
    }

    let subject = PassthroughSubject<Tab, Never>()

    enum Tab: Int {
        case tab1 = 0
    }
}
struct HomeView: View {
    @StateObject var viewModel: HomeViewModel = .init()

    var body: some View {
        TabView(selection: $viewModel.selectedTab) {
            Tab1View(subject: viewModel.subject)
                .tag(HomeViewModel.Tab.tab1)
                .tabItem {
                    Label("Tab 1", systemImage: "1.lane")
                    Text("Tab 1", comment: "Tab bar title")
                }
        }
    }
}
struct Tab1View: View {
    @StateObject var viewModel: Tab1ViewModel = .init()
    let subject: PassthroughSubject<HomeViewModel.Tab, Never>

    var body: some View {
        NavigationStack(path: $viewModel.path) {
            List {
                NavigationLink(value: Tab1ViewModel.Route.viewOne("From tab 1")) {
                    Text("Go deeper to OneView")
                }
                NavigationLink(value: Tab1ViewModel.Route.viewTwo("From tab 1")) {
                    Text("Go deeper to TwoView")
                }
            }
            .navigationTitle("Tab 1")
            .navigationDestination(for: Tab1ViewModel.Route.self, destination: { route in
                switch route {
                case let .viewOne(text):
                    Text(text)
                case let .viewTwo(text):
                    Text(text)
                }
            })
            .onReceive(subject) { tab in
                if case .tab1 = tab { viewModel.tabBarTapped() }
            }
        }
    }
}
class Tab1ViewModel: ObservableObject {
    @Published var path: [Route] = []

    func tabBarTapped() {
        if path.count > 0 {
            path.removeAll()
        }
    }
    enum Route: Hashable {
        case viewOne(String)
        case viewTwo(String)
    }
}

Upvotes: 4

apocolipse
apocolipse

Reputation: 617

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 {
  ...
}

Upvotes: 0

Spencer Shelton
Spencer Shelton

Reputation: 391

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

Upvotes: 1

Filipe S&#225;
Filipe S&#225;

Reputation: 382

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

Upvotes: 9

Luke Howard
Luke Howard

Reputation: 525

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

Upvotes: 10

Asperi
Asperi

Reputation: 257493

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

Upvotes: 15

Related Questions