Supporting a sidebar on iPad and a tab bar on iPhone from one navigation model used to mean maintaining two separate pieces of UI — a TabView for compact width and a NavigationSplitView with its own selection state for regular width — kept in sync by hand.
#The old way
if horizontalSizeClass == .compact {
TabView(selection: $selection) {
HomeView().tag(AppTab.home)
SearchView().tag(AppTab.search)
}
} else {
NavigationSplitView {
List(selection: $selection) {
Label("Home", systemImage: "house").tag(AppTab.home)
Label("Search", systemImage: "magnifyingglass").tag(AppTab.search)
}
} detail: {
selection.destinationView
}
}
#The new way
enum AppTab: Hashable {
case home, search, library, profile
}
struct RootView: View {
@State private var selection: AppTab = .home
var body: some View {
TabView(selection: $selection) {
Tab("Home", systemImage: "house", value: AppTab.home) {
HomeView()
}
Tab("Search", systemImage: "magnifyingglass", value: AppTab.search) {
SearchView()
}
Tab("Library", systemImage: "books.vertical", value: AppTab.library) {
LibraryView()
}
Tab("Profile", systemImage: "person.crop.circle", value: AppTab.profile) {
ProfileView()
}
}
.tabViewStyle(.sidebarAdaptable)
}
}
#Why it matters
- One
TabViewdeclaration drives both layouts — no duplicated selection state to keep in sync - iPad gets a real sidebar with a built-in collapse toggle; iPhone keeps the standard bottom tab bar
- Value-based
Tabitems compose withTabSectionfor grouping tabs under headings when the sidebar is showing
#Gotcha
.tabViewStyle(.sidebarAdaptable) only works when every tab is declared with the new Tab builder syntax — you can't mix in the older .tabItem() modifier inside the same TabView, it either fails to compile or silently drops the sidebar behavior. If you add Tab(role: .search) for a dedicated search field, it has to live alongside the other Tab values in the same builder too; mixing Tab(role:) with .tabItem() is a documented compile error.