TabView and Sheets
TabView and sheet(isPresented:) (or sheet(item:)) solve two different presentation problems. TabView gives users persistent, always-visible access to several top-level sections of an app — think Mail's Mailboxes/VIP/Flagged tabs — where switching tabs preserves each tab's own state and navigation stack. Sheets, by contrast, present transient, focused content over the current screen — a compose view, a settings panel, a confirmation flow — that the user is expected to dismiss and return from. Choosing correctly between them is largely about whether the content is a persistent destination or a temporary task.
Cricket analogy: Like an app's persistent Live/Scorecard/News tabs (TabView) versus a modal popup showing a single wicket review decision (sheet) that you dismiss and return from, the choice hinges on whether content is a destination or a temporary task.
Building a TabView
Each tab is typically its own NavigationStack, so that navigating within one tab doesn't affect the others, and switching back to a previously visited tab restores exactly where the user left off. Modern SwiftUI lets you label tabs with the Tab type for cleaner syntax and supports a selection binding when you need to programmatically switch tabs, for example after handling a notification tap that should land the user on a specific section.
Cricket analogy: Like each format having its own independent scorecard (Test, ODI, T20) so switching formats doesn't reset progress in another, each TabView tab typically owns its own NavigationStack, preserving where you left off when you switch back.
struct RootTabView: View {
@State private var selection: AppTab = .home
var body: some View {
TabView(selection: $selection) {
Tab("Home", systemImage: "house", value: .home) {
NavigationStack { HomeView() }
}
Tab("Orders", systemImage: "bag", value: .orders) {
NavigationStack { OrdersListView() }
}
Tab("Profile", systemImage: "person", value: .profile) {
NavigationStack { ProfileView() }
}
}
}
}
enum AppTab: Hashable {
case home, orders, profile
}Presenting Sheets
A sheet is attached with .sheet(isPresented:) for a simple boolean trigger, or .sheet(item:) when the content depends on a specific optional value (for example, editing a specific item selected from a list). sheet(item:) is often preferable because it guarantees the sheet's content view always has the non-optional data it needs, avoiding the awkward 'isPresented is true but the selected item is nil' state that isPresented-based sheets can fall into.
Cricket analogy: Like a DRS review triggered by a simple 'review requested' boolean flag (sheet(isPresented:)) versus one triggered by selecting a specific delivery to re-examine (sheet(item:)), the latter guarantees the review screen always has the actual ball data it needs.
struct OrdersListView: View {
@State private var selectedOrder: Order?
var body: some View {
List(orders) { order in
Button(order.title) { selectedOrder = order }
}
.sheet(item: $selectedOrder) { order in
OrderDetailSheet(order: order)
}
}
}Sheet Sizing and Dismissal
presentationDetents lets a sheet snap to specific heights (.medium, .large, or a custom fraction/height), which is especially useful for lightweight actions that don't need full-screen real estate. Inside the sheet's content, an @Environment(\.dismiss) private var dismiss property gives the presented view a way to close itself programmatically, for example after a form successfully submits, without needing the parent to manage that state directly.
Cricket analogy: Like a quick over-by-over summary popping up at a half-screen height (.medium) instead of taking over the whole broadcast, presentationDetents lets a sheet snap to compact sizes, and @Environment(\.dismiss) lets it close itself after the review completes.
A frequent mistake is using .sheet(isPresented:) alongside a separately-managed optional value, where the boolean and the value can get out of sync (e.g. isPresented becomes true before the value is set, briefly showing an empty or crashing sheet). Prefer .sheet(item:) whenever the sheet's content is naturally tied to a specific optional value.
TabView tabs are like separate rooms in a house you can walk between at will, retaining their own furniture arrangement (navigation state); a sheet is like pulling down a projector screen over the room you're already in — temporary, task-focused, and expected to roll back up when you're done.
- TabView provides persistent, switchable access to top-level app sections, each ideally with its own NavigationStack.
- Sheets present transient, dismissible, task-focused content layered over the current screen.
- sheet(item:) ties presentation directly to an optional value's presence, avoiding isPresented/data desync bugs.
- presentationDetents controls how much of the screen a sheet occupies (.medium, .large, or custom sizes).
- @Environment(\.dismiss) lets a presented view close itself without the parent directly managing that state.
- Choose TabView for persistent destinations and sheets for temporary, focused tasks.
Practice what you learned
1. What is the key difference in intent between TabView and a sheet?
2. Why is sheet(item:) often preferred over sheet(isPresented:) when the sheet's content depends on a specific selected value?
3. What does presentationDetents control on a sheet?
4. How can a view presented inside a sheet dismiss itself without the parent view directly managing the presentation state?
5. Why does each tab in a TabView typically get its own NavigationStack?
Was this page helpful?
You May Also Like
NavigationStack Basics
Understand how NavigationStack replaced NavigationView, how navigationDestination drives push navigation, and how to structure hierarchical screens in SwiftUI.
NavigationPath and Programmatic Navigation
Learn how NavigationPath lets you drive SwiftUI navigation from code — deep linking, popping to root, and building multi-step flows without user taps.
App and Scene Structure
Understand how the App and Scene protocols define a SwiftUI application's entry point, window management, and lifecycle across platforms.
@State and @Binding
Understand how @State owns local view state and how @Binding lets child views read and write that state without owning it themselves.