2

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?

1
  • 1
    I feel like I've seen this question before. Note, TabView should not be inside a NavigationStack because they interfere with each other's way of navigation. Restructure your code. Commented Jan 26 at 4:08

1 Answer 1

0

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

Sign up to request clarification or add additional context in comments.

5 Comments

Thanks, Andrei! I have one more follow-up question regarding this implementation. In the native Files app, when switching between tabs, the toolbar items remain unchanged without any animation, likely because they are not part of the NavigationStack. Could you share your thoughts on this?
@jacob A toolbar will only display if it is part of a NavigationStack. The only way I found to disable the TabView animation is to use .id(UUID()) on the TabView, which may have its drawbacks depending on the complexity of the views in the tabs. But if you add it and say, remove the favorite toolbar button from the Browse tab, you will see the Settings button remain and without animation when changing tabs.
You're right! Applying .id(UUID()) to the TabView effectively stops the toolbar items from animating during tab changes. However, this method also disables all animations within the TabView, including the animations for switching tabs.
I managed to disable the tab switch animation, but it required using UIKit alongside SwiftUI, as discussed in this thread: https://developer.apple.com/forums/thread/763122.
@jacob Great, thanks for the follow up.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.