swiftuiipadtabsios18

How to Add Leading and Trailing Buttons in a Tab Bar Like the iPad Files App in iOS 18?


I'm trying to design a SwiftUI view with a tab bar positioned at the top of the screen, including buttons on the leading and trailing sides of the tab bar, similar to the layout of the tab bar in Apple's Files app on iPad (refer to the screenshot)

iPad files app reference

I currently have the following implementation using TabView:

struct MainView: View {
    var body: some View {
        NavigationStack {
            // Main TabView placed on top of the app bar, below the toolbar
            TabView {
                HomeView2()
                    .tabItem {
                        Label("Home", systemImage: "house")
                    }
                
                BrowseView()
                    .tabItem {
                        Label("Browse", systemImage: "magnifyingglass")
                    }
                
                ProfileView2()
                    .tabItem {
                        Label("Profile", systemImage: "person")
                    }
            }
            .toolbar(content: {
                ToolbarItem(placement: .topBarTrailing) {
                    Image(systemName: "heart.fill")
                }
            })
            .navigationBarItems(
                leading: Image(systemName: "heart.fill"),
                trailing: Image(systemName: "gear")
            )
            .toolbar(.visible, for: .navigationBar)
            .toolbarBackground(.visible, for: .navigationBar)
            .toolbarBackground(Color.brown, for: .navigationBar)
            .navigationBarTitleDisplayMode(.inline)
        }
    }
}

The result of this code looks like this App screenshot

How can I create a SwiftUI view with a tab bar positioned at the top of the screen, along with buttons on the leading and trailing side of tab bar, similar to the layout of the tab bar in Apple's Files app on iPad?


Solution

  • FYI, .navigationBarItems is deprecated. You should use the replacement instead.

    Although I am not sure exactly of the logic/setup used in the Files app, it does seem to use a TabView with .sidebarAdaptable style. This modifier is new to iOS 18 so you'll need to work in a project supporting iOS 18+.

    When using multiple tabs, it's best for each tab to have its own NavigationStack so you can control in which tab you want to navigate to a specific view. Then, each tab view or any of its children can contribute toolbar items to its navigation stack.

    .navigationTitle("Browse")
    .toolbar {
        ToolbarItem(placement: .primaryAction) {
            Button {
                favoriteTab.toggle()
            } label: {
                Image(systemName: favoriteTab ? "heart.fill" : "heart")
            }
        }
    }
    

    If you want to have the same button available in all tabs (like the settings button), you can use a view extension function to define the toolbar item and then use that modifier on any view that should display a settings button in its navigation stack. In the example below, look at the .settingsToolbarItem view extension.

    .settingsToolbarItem(showNavigationArrows: true)
    

    In this case, if the settings buttons is available in all tabs, the question becomes in which tab should the Settings view be displayed? For the purposes of the code below, the settings view should be displayed in the Home tab, which requires logic for controlling the selected tab from any view (or function).

    In my experience, this is most easily achieved using a shared observable singleton, like the NavigationManager class shown in the example code below, since you can access it or bind to it from anywhere without having to pass it to any or all views that may need it. This class also has properties for the navigation paths of each tab.

    //Observable singleton
    @Observable
    class NavigationManager {
    
        //Properties
        var selectedTab: Int = 1
        
        var homeNavigationPath: [NavigationRoute] = []
        var browseNavigationPath: NavigationPath = NavigationPath()
        var profileNavigationPath: NavigationPath = NavigationPath()
        
        //Singleton
        static let nav = NavigationManager()
        
        private init() {}
    }
    

    How you want to go about the navigation paths is up to you, but as an example, I showed one way using a navigation route enum configured for the Home tab destinations:

    //Navigation route enum
    enum NavigationRoute {
        case settings
        
        var route: some View {
            switch self {
                case .settings: SettingsView()
            }
        }
    }
    

    ... and the associated config in HomeTabView:

    .navigationDestination(for: NavigationRoute.self) { destination in
                    destination.route
                }
    

    With this setup, navigating to Settings in the Home tab becomes as simple as:

    let nav = NavigationManager.nav
    Button {
        //Switch to home tab
        nav.selectedTab = 1
                        
        //Navigate to settings using home tab's navigation stack
        nav.homeNavigationPath.append(.settings)
    } label: {
        Image(systemName: "gear")
    }
    

    You can still use a NavigationLink like in the Profile tab, for example:

    .navigationTitle("Profile")
    .toolbar {
        ToolbarItem(placement: .primaryAction) {
            NavigationLink {
                VStack {
                    Text("Some content for adding a profile...")
                }
                .navigationTitle("Add profile")
            } label: {
                Label("Add profile", systemImage: "person.crop.circle.badge.plus")
            }
        }
    }
    

    Full code:

    import SwiftUI
    import Observation
    
    //Root view
    struct ContentView: View {
        
        //State values
        @State private var nav: NavigationManager = NavigationManager.nav // <- initialize navigation manager singleton
        
        //Body
        var body: some View {
            
            TabView(selection: $nav.selectedTab) {
                
                Tab("Home", systemImage: "house", value: 1){
                    HomeTabView()
                }
                
                Tab("Browse", systemImage: "folder", value: 2){
                    BrowseTabView()
                }
                
                Tab("Profile", systemImage: "person.crop.circle", value: 3){
                    ProfileTabView()
                }
            }
            .tabViewStyle(.sidebarAdaptable)
            .tabViewSidebarHeader {
                Text("Files")
                    .font(.largeTitle)
                    .fontWeight(.bold)
                    .frame(maxWidth: .infinity, alignment: .leading)
                
            }
            
        }
    }
    
    //Home view
    struct HomeTabView: View {
        
        //Binding to observable navigation manager singleton
        @Bindable var navigationManager = NavigationManager.nav
        
        //Body
        var body: some View {
            
            NavigationStack(path: $navigationManager.homeNavigationPath) {
                VStack {
                    ContentUnavailableView {
                        Label("No content", systemImage: "questionmark.circle.fill")
                    } description: {
                        Text("Nothing to show at this time.")
                    }
                }
                .navigationTitle("Home")
                .settingsToolbarItem()
                .navigationDestination(for: NavigationRoute.self) { destination in
                    destination.route
                }
            }
        }
    }
    
    //Browse view
    struct BrowseTabView: View {
        
        //Binding to observable navigation manager singleton
        @Bindable var navigationManager = NavigationManager.nav
        
        //State values
        @State private var favoriteTab = false
        
        //Body
        var body: some View {
            
            NavigationStack(path: $navigationManager.browseNavigationPath) {
                VStack {
                    ContentUnavailableView {
                        Label("No files", systemImage: "questionmark.folder.fill")
                    } description: {
                        Text("No files available.")
                    }
                }
                .navigationTitle("Browse")
                .toolbar {
                    ToolbarItem(placement: .primaryAction) {
                        Button {
                            favoriteTab.toggle()
                        } label: {
                            Image(systemName: favoriteTab ? "heart.fill" : "heart")
                        }
                    }
                }
                .settingsToolbarItem(showNavigationArrows: true)
            }
        }
    }
    
    //Profile view
    struct ProfileTabView: View {
        
        //Binding to observable navigation manager singleton
        @Bindable var navigationManager = NavigationManager.nav
        
        //Body
        var body: some View {
            
            NavigationStack(path: $navigationManager.profileNavigationPath) {
                VStack {
                    ContentUnavailableView {
                        Label("No profile", systemImage: "person.crop.circle.badge.questionmark.fill")
                    } description: {
                        Text("No user profile.")
                    }
                }
                .navigationTitle("Profile")
                .toolbar {
                    ToolbarItem(placement: .primaryAction) {
                        NavigationLink {
                            VStack {
                                Text("Some content for adding a profile...")
                            }
                            .navigationTitle("Add profile")
                        } label: {
                            Label("Add profile", systemImage: "person.crop.circle.badge.plus")
                        }
                    }
                }
            }
        }
    }
    
    //Settings view
    struct SettingsView: View {
        
        var body: some View {
            
            VStack {
                ContentUnavailableView {
                    Label("No settings", systemImage: "gear.badge.questionmark")
                } description: {
                    Text("No settings configured.")
                }
            }
            .navigationTitle("Settings")
            .toolbar {
                ToolbarItem(placement: .secondaryAction) {
                    Button {
                        //action here...
                    } label: {
                        Label("Reset settings", systemImage: "gearshape.arrow.trianglehead.2.clockwise.rotate.90")
                    }
                }
            }
            
        }
    }
    
    //Navigation route enum
    enum NavigationRoute {
        case settings
        
        var route: some View {
            switch self {
                case .settings: SettingsView()
            }
        }
    }
    
    //View extension
    extension View {
        func settingsToolbarItem(showNavigationArrows: Bool = false) -> some View {
            self
                .toolbar {
                    ToolbarItem(placement: .primaryAction) {
                        let nav = NavigationManager.nav
                        Button {
                            //Switch to home tab
                            nav.selectedTab = 1
                            
                            //Navigate to settings using home tab's navigation stack
                            nav.homeNavigationPath.append(.settings)
                        } label: {
                            Image(systemName: "gear")
                        }
                    }
                    
                    if showNavigationArrows {
                        ToolbarItemGroup(placement: .topBarLeading) {
                            Button {
                                //action here...
                            } label: {
                                Image(systemName: "chevron.left")
                            }
                            
                            Button {
                                //action here...
                            } label: {
                                Image(systemName: "chevron.right")
                            }
                            .disabled(true)
                        }
                    }
                }
        }
    }
    
    //Observable singleton
    @Observable
    class NavigationManager {
    
        //Properties
        var selectedTab: Int = 1
        
        var homeNavigationPath: [NavigationRoute] = []
        var browseNavigationPath: NavigationPath = NavigationPath()
        var profileNavigationPath: NavigationPath = NavigationPath()
        
        //Singleton
        static let nav = NavigationManager()
        
        private init() {}
    }
    
    #Preview {
        ContentView()
    }
    

    enter image description here

    enter image description here