Search code examples
iosswiftswiftui

How do I add a toolbar to my tab view in SwiftUI?


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 NavigationStacks 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?


Solution

  • 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:

    • Each tab has its own NavigationStack that uses its own navigation path.
    • To manage navigation, state of tabs and the navigation state of which, an observable NavigationManager class is used as a singleton (only one instance of it exists and can be accessed throughout the app).
    • Link management is centralized in an enum where all the possible destination are defined (NavigationDestination)
    • Tab selection is simplified by setting the selectedTab property of the navigation singleton to the desired tab number.
    • View extensions are used to define functions that will add toolbar, searchbar, etc., wherever needed. (.addToolbar(), .addSearchbar(),...)

    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:

    • Tapping the apple logo in the toolbar will take you to the root view of the Home tab
    • Tapping the profile icon in the toolbar (from no matter which tab you're in) will take you to the user profile page in the Settings tab.
    • Tapping any of the buttons on the welcome page will switch to the respective tab.
    • Tapping a city name in the Cities tab will navigate to a city detail view in the same tab.
    • Once in a city detail view, Tapping on the (already selected) Cities tab will pop it back to the Cities root view (no matter how deep inside the navigation you are).

    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.

    enter image description here