In my app I want a tab bar at the bottom and a bar at the top that contains some static information. For my current solution I'm wrapping the tab view in a NavigationStack
, but I understand that the tab view should always be the first in the view hierarchy. With my current solution I only have to define the top bar once and it is present for all tabs. How can I have the tab view be the top view in the view hierarchy without having to add a navigation stack and the bar to each tab?
My current solution:
struct ContentView: View {
var body: some View {
NavigationStack {
TabView {
Group {
FirstView()
.tabItem {
Label("One", systemImage: "1.circle")
}
SecondView()
.tabItem {
Label("Two", systemImage: "2.circle")
}
}
}
}
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Image(systemImage: "apple.logo"
}
ToolbarItem(placement: .topBarTrailing) {
Image(systemImage: "person.circle"
}
}
}
}
I've tried adding the toolbar to the Group
inside the tab view instead, but that doesn't work.
Edit
My solution was to move the .toolbar
modifier to the TabView
as suggested by @Raahs. Sadly I've found out why the NavigationStack
should be inside the TabView
and not the otter way around (as I have). For one of the views in the tab view I've added a search bar using the .searchable
modifier. When that view is loaded the search bar is added to the toolbar and 'sticks', when you go to a different tab in the tab view the search bar persists.
Removing the NavigationStack
around the TabView
and adding NavigationStack
s to each view in the tab view is the obvious and recommended suggestion. That would mean copying the .toolbar
modifier to each of those views (the toolbar relies on data fetched using a .task
modifier, that would need to be copied as well).
Is there a way to have all views in the tab view have their own NavigationStack
but use the same code to fill the toolbar?
There sure is - you just need to make use of SwiftUI concepts and make things reusable.
You already know your approach is not the recommended way to go about it, not only because of the TabView inside a NavigationStack, but because the many challenges you will face down the road as far as navigation and flexibility goes.
When using multiple tabs and a common toolbar (or even without a common toolbar), you need to be able to control the tab a link should open in. As your app evolves, you will have links all over the place and without such flexibility you'll hit roadblocks in no time.
I attached an example based on your use case. Typically this would be divided in separate files, but I kept it all together for easier copying.
Here are some key points:
With all this structure in place, everything becomes very easy.
For example, to add the toolbar to any view:
var body: some View {
VStack {
Text("Some view content...")
}
.addToolbar() //use the view extension function/modifier
}
To add the searchable bar to any view (that has a toolbar), just add another modifier:
.addSearchbar(text: $searchText, prompt: "Search term...")
This allows you to have views that have a toolbar, or views without a toolbar, or views with a toolbar and a searchbar.
The searchbar modifier also has a placeholder for a .task
modifier, since you mentioned that's where you fetch data. It could also support multiple search bars with various data sources, by adding some parameters to the function and adapting the logic accordingly.
So now you see that these view extension functions is one way of adding the same code to fill the toolbar.
Similarly, in order to have navigation support by adding the .navigationDestination(for:
modifier to each tab's NavigationStack
, we define the modifier in a view extension and then simply add it to the stack of each tab:
var body: some View {
NavigationStack {
VStack {
Text("Some view content...")
}
.addNavigationSupport() // <-- Here
}
}
If you copy and run the attached code, as you explore the app, you'll notice:
Here's the example code:
import SwiftUI
// Define your navigation data model
enum NavigationDestination: Hashable, View {
case home
case profile
case settings
case city(String)
// return the associated view for each case
var body: some View {
switch self {
case .home:
HomeView()
case .profile:
ProfileView()
case .settings:
SettingsView()
case .city(let name):
CityDetailView(name: name)
.toolbarTitleDisplayMode(.large)
}
}
}
// Define a singleton class for managing navigation
@Observable
final class NavigationManager {
var selectedTab = 1
//Tab handler for pop to root on tap of selected tab
var tabHandler: Binding<Int> { Binding(
get: { self.selectedTab },
// React to taps on the tap item
set: {
// If the current tab selection gets tapped again
if $0 == self.selectedTab {
switch $0 {
case 1:
self.mainNavigator = [] //reset the navigation path
case 2:
self.cityNavigator = []
case 3:
self.animalNavigator = []
case 4:
self.settingsNavigator = []
default:
self.mainNavigator = []
}
}
self.selectedTab = $0
}
) }
static let nav = NavigationManager() //also commonly called "shared"
var mainNavigator: [NavigationDestination] = []
var cityNavigator: [NavigationDestination] = []
var animalNavigator: [NavigationDestination] = []
var settingsNavigator: [NavigationDestination] = []
private init() {}
}
//Root view
struct SearchbarTabs: View {
//Get a binding to the navigation singleton
@Bindable private var navigator = NavigationManager.nav
var body: some View {
TabView(selection: navigator.tabHandler) {
HomeView()
.tabItem {
Label("Welcome", systemImage: "house")
}
.tag(1)
CityTabView()
.tabItem {
Label("Cities", systemImage: "building.2.fill")
}
.tag(2)
AnimalTabView()
.tabItem {
Label("Animals", systemImage: "tortoise.fill")
}
.tag(3)
SettingsView()
.tabItem {
Label("Settings", systemImage: "gearshape.fill")
}
.tag(4)
}
}
}
struct HomeView: View {
@Bindable private var navigator = NavigationManager.nav
var body: some View {
NavigationStack(path: $navigator.mainNavigator) {
VStack{
Text("Select:")
Group {
Button("Find a city"){
navigator.selectedTab = 2
}
Button("Find an animal"){
navigator.selectedTab = 3
}
Button("Go to Settings"){
navigator.selectedTab = 4
}
.tint(.secondary)
}
.frame(maxWidth: .infinity, alignment: .center)
.buttonStyle(BorderedProminentButtonStyle())
}
.frame(maxWidth: .infinity, alignment: .center)
.addToolbar()
.addNavigationSupport()
.navigationTitle("Welcome")
}
}
}
struct CityTabView: View {
@Bindable private var navigator = NavigationManager.nav
@State private var searchText = ""
let cities = ["New York", "Los Angeles", "Chicago", "Houston", "Phoenix", "Philadelphia", "San Antonio", "San Diego", "Dallas", "San Jose"]
// Filtered cities based on the search text
var filteredCities: [String] {
if searchText.isEmpty {
return cities
} else {
return cities.filter { $0.localizedCaseInsensitiveContains(searchText) }
}
}
var body: some View {
NavigationStack(path: $navigator.cityNavigator) {
List(filteredCities, id: \.self) { city in
NavigationLink(value: NavigationDestination.city(city)){
Text(city)
}
}
.navigationTitle("City Search")
.toolbarTitleDisplayMode(.inline)
.addToolbar()
.addNavigationSupport()
.addSearchbar(text: $searchText, prompt: "Search a city")
}
}
}
struct CityDetailView: View {
var name: String
var body: some View {
VStack {
Text("This is \(name)")
}
.navigationTitle(name)
}
}
struct AnimalTabView: View {
@Bindable private var navigator = NavigationManager.nav
@State private var searchText = ""
let animals = ["Lion", "Tiger", "Elephant", "Giraffe", "Zebra", "Penguin", "Kangaroo", "Panda", "Koala", "Leopard"]
// Filtered cities based on the search text
var filteredAnimals: [String] {
if searchText.isEmpty {
return animals
} else {
return animals.filter { $0.localizedCaseInsensitiveContains(searchText) }
}
}
var body: some View {
NavigationStack(path: $navigator.animalNavigator) {
List(filteredAnimals, id: \.self) { animal in
Text(animal)
}
.navigationTitle("Animal Search")
.toolbarTitleDisplayMode(.inline)
.addToolbar()
.addNavigationSupport()
.addSearchbar(text: $searchText, prompt: "Search an animal")
}
}
}
struct SettingsView: View {
@Bindable private var navigator = NavigationManager.nav
var body: some View {
NavigationStack(path: $navigator.settingsNavigator) {
VStack{
Text("Links:")
Button("Profile"){
navigator.settingsNavigator.append(.profile)
}
.frame(maxWidth: .infinity, alignment: .center)
.buttonStyle(BorderedProminentButtonStyle())
}
.frame(maxWidth: .infinity, alignment: .center)
.addToolbar()
.addNavigationSupport()
.navigationTitle("Settings")
}
}
}
struct ProfileView: View {
var body: some View {
VStack {
Image(systemName: "person.circle")
.font(.system(size: 100))
.foregroundStyle(.secondary)
Text("John Doe")
.font(.largeTitle)
Text("Author")
.foregroundStyle(.secondary)
}
}
}
//View extension
extension View {
//addToolbar
func addToolbar() -> some View {
self
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button {
let navigator = NavigationManager.nav
navigator.selectedTab = 1
navigator.mainNavigator = []
} label: {
Image(systemName: "apple.logo")
}
}
ToolbarItem(placement: .topBarTrailing) {
Button {
NavigationManager.nav.selectedTab = 4
NavigationManager.nav.settingsNavigator = [.profile]
} label: {
Image(systemName: "person.circle")
}
}
}
}
//addSearchbar
func addSearchbar(text: Binding<String>, prompt: String) -> some View {
self
.searchable(text: text, prompt: prompt)
.task {
//action code here...
}
}
//addNavigationSupport
func addNavigationSupport() -> some View {
self
.navigationDestination(for: NavigationDestination.self) { destination in
destination // The enum itself returns the view
}
}
}
//App Main
@main
struct SearchbarTabsApp: App {
//initialize the navigation singleton
@State private var navigator = NavigationManager.nav
var body: some Scene {
WindowGroup {
SearchbarTabs()
//share the navigation singleton via environment
.environment(navigator)
}
}
}
#Preview {
SearchbarTabs()
}
Although this may seem like a lot, I think it's the most basic approach to having the navigation flexibility needed when using multiple tabs.
If you like this approach to Navigation, consider the Routing library, which does all this and then some by providing some useful functions for navigation. It's what I am currently using, although I wish it was updated for the newer @Observable
macro introduced in iOS 17.