Tap tab button to pop to root with NavigationStack and SwiftUI

Tags: iOS, Swift, SwiftUI, TabView, NavigationStack, Combine, Pop to root

root

Published: 2023-01-15

I tried to find a decent way to pop to root with the setup I had in my project, but wasn't quite satisfied with what I found, so I decided to do a short write up on how I solved it.

This post consists of the following parts:

  1. Getting the tab button touch event
  2. Passing the event to the different child views
  3. Setting up the tab views with navigation stack
  4. Handling the event and popping views from the navigation stack

Getting the tab button touch event

Adding a onTapGesture to the TabView won't work as the whole screen will receive the touch and it will block the touch event for the underlying views.

Instead we can utilize the willSet for the selectedTab property to get a read on when the tab button is tapped.

@Published var selectedTab: Tab = .tab1 {
  willSet { }
}

Passing the event to the different child views

Further to make sure that this is a tap on active tab and not another tab, we check that the values are the same and then send the info down the stack. The HomeViewModel will then look like this:

import Foundation
import Combine

class HomeViewModel: ObservableObject {
  @Published var selectedTab: Tab = .tab1 {
    willSet {
      // Check if it's a tap on the same tab and fire
      if selectedTab == newValue {
        subject.send(newValue)
      }
    }
  }
  // Add a PassthroughSubject
  let subject = PassthroughSubject<Tab, Never>()
}

extension HomeViewModel {
  enum Tab: Int {
    case tab1 = 0
    case tab2 = 1
  }
}

The HomeView will then look like this where the PassthroughSubject is passed down to the different root views in the TabView:

import SwiftUI

struct HomeView: View {
  @StateObject var viewModel: HomeViewModel = .init()

  var body: some View {
    TabView(selection: $viewModel.selectedTab) {
      // Pass the subject
      Tab1View(subject: viewModel.subject)
        .tag(HomeViewModel.Tab.tab1)
        .tabItem {
          Label("Tab 1", systemImage: "1.lane")
          Text("Tab 1", comment: "Tab bar title")
        }

      // Pass the subject
      Tab2View(subject: viewModel.subject)
        .tag(HomeViewModel.Tab.tab2)
        .tabItem {
          Label("Tab 2", systemImage: "2.lane")
          Text("Tab 2", comment: "Tab bar title")
        }
    }
  }
}

Setting up the tab views with navigation stack

We have two tab root views in this demo: Tab1View and Tab2View which are identical except from their name, and look like this:

import SwiftUI
import Combine

struct Tab1View: View {
  @StateObject var viewModel: Tab1ViewModel = .init()
  // The passed down subject
  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")
        }
      }
      .navigationDestination(for: Tab1ViewModel.Route.self, destination: { route in
        switch route {
        case let .viewOne(text):
          OneView(text: text)
        case let .viewTwo(text):
          TwoView(text: text)
        }
      })
    }
  }
}

Their accompanying view model looks like this:

class Tab1ViewModel: ObservableObject {
  @Published var path: [Route] = []
}

extension Tab1ViewModel {
  enum Route: Hashable {
    case viewOne(String)
    case viewTwo(String)
  }
}

Alright, so the possible routes are defined and the navigation stack is set up. The OneView and TwoView are just two simple views taking a String value for displaying.

Handling the event and popping views from the navigation stack

What's left now is just to handle the tap event that is sent in the willSet function above. For this we need a function to pop the views from the path in the view model and a receiever for the subject's send calling the function in the view model. So first add the following to the Tab1ViewModel:

func tabBarTapped() {
  if path.count > 0 {
    // Pop to root
    path.removeAll()
  }
}

This will be called from the Tab1View in the receiver. Add the following to the Tab1View just under the .navigationDestination block:

// Here we receieve the subject's send function
.onReceive(subject) { tab in
  // Check to see if it's the correct tab that is tapped, and if so call the view model's function
  if case .tab1 = tab {
    viewModel.tabBarTapped()
  }
}

And that's about it! A full demo project showing everything tied together can be found here

Thank you!