Tags: iOS, Swift, SwiftUI, TabView, NavigationStack, Combine, Pop to 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:
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 { }
}
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")
}
}
}
}
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.
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!