- Published on
Control Sheet Height with presentationDetents in SwiftUI
- Authors

- Name
- Mick MacCallum
- @0x7fs
Before iOS 16, SwiftUI sheets always appeared at full screen height, which wasn't always ideal for displaying small amounts of content. The presentationDetents() modifier, introduced in iOS 16, gives you fine-grained control over sheet heights, allowing you to create interactive bottom sheets similar to those in Apple Maps, Apple Music, and other system apps.
What Are Presentation Detents?
A presentation detent is a height where a sheet naturally rests. Think of detents as "snap points" - when a user drags a sheet, it snaps to one of these predefined heights. SwiftUI provides built-in detents and allows you to create custom ones based on fractions of screen height or fixed point values.
Built-in Detents
SwiftUI provides several built-in presentation detents:
.medium- Approximately half the screen height.large- Nearly full screen height (default behavior).fraction(CGFloat)- A percentage of screen height (0.0 to 1.0).height(CGFloat)- A fixed height in points
Basic Usage
The simplest use case is restricting a sheet to a single detent:
struct ContentView: View {
@State private var showSheet = false
var body: some View {
Button("Show Sheet") {
showSheet = true
}
.sheet(isPresented: $showSheet) {
SheetContent()
.presentationDetents([.medium])
}
}
}
struct SheetContent: View {
var body: some View {
VStack(spacing: 20) {
Text("Medium Height Sheet")
.font(.headline)
Text("This sheet appears at approximately half screen height.")
.multilineTextAlignment(.center)
.padding()
}
.padding()
}
}
With .presentationDetents([.medium]), the sheet appears at medium height and cannot be dragged to full screen.
Multiple Detents
You can provide multiple detents to allow users to drag the sheet between different heights:
struct ContentView: View {
@State private var showSheet = false
var body: some View {
Button("Show Sheet") {
showSheet = true
}
.sheet(isPresented: $showSheet) {
SheetContent()
.presentationDetents([.medium, .large])
}
}
}
Now users can drag the sheet from medium to full height and back. The sheet will snap to these heights when released.
Tracking Active Detent
You can bind to a state variable to track which detent is currently active:
struct ContentView: View {
@State private var showSheet = false
@State private var selectedDetent: PresentationDetent = .medium
var body: some View {
VStack {
Text("Current detent: \(selectedDetent == .medium ? "Medium" : "Large")")
Button("Show Sheet") {
showSheet = true
}
}
.sheet(isPresented: $showSheet) {
SheetContent()
.presentationDetents(
[.medium, .large],
selection: $selectedDetent
)
}
}
}
This is useful for adapting your UI based on the sheet's current height.
Using Fraction-Based Detents
The .fraction() detent allows you to specify any percentage of screen height:
struct ContentView: View {
@State private var showSheet = false
var body: some View {
Button("Show Sheet") {
showSheet = true
}
.sheet(isPresented: $showSheet) {
SheetContent()
.presentationDetents([
.fraction(0.25), // Quarter height
.fraction(0.50), // Half height
.fraction(0.75) // Three-quarter height
])
}
}
}
Using Fixed Height Detents
The .height() detent provides a fixed height in points, which is useful when you know exactly how much space your content needs:
struct ContentView: View {
@State private var showSheet = false
var body: some View {
Button("Show Sheet") {
showSheet = true
}
.sheet(isPresented: $showSheet) {
SheetContent()
.presentationDetents([
.height(200),
.height(400),
.large
])
}
}
}
This is particularly useful for sheets with known content heights, like action menus or forms with a specific number of fields.
Custom Detents
For more complex requirements, you can create custom detents that calculate their height based on the context:
extension PresentationDetent {
static let bar = Self.height(100)
static let small = Self.fraction(0.30)
static let extraLarge = Self.fraction(0.95)
}
struct ContentView: View {
@State private var showSheet = false
var body: some View {
Button("Show Sheet") {
showSheet = true
}
.sheet(isPresented: $showSheet) {
SheetContent()
.presentationDetents([.bar, .small, .medium, .extraLarge])
}
}
}
Adaptive Detents with Dynamic Content
You can adapt detents based on content size using GeometryReader:
struct AdaptiveSheetContent: View {
let items: [String]
var body: some View {
ScrollView {
VStack(spacing: 16) {
ForEach(items, id: \.self) { item in
Text(item)
.padding()
.frame(maxWidth: .infinity)
.background(Color.blue.opacity(0.1))
.cornerRadius(8)
}
}
.padding()
}
.presentationDetents([
.height(CGFloat(min(items.count, 3)) * 80 + 100),
.large
])
}
}
Combining with Other Presentation Modifiers
Presentation detents work seamlessly with other presentation modifiers:
struct ContentView: View {
@State private var showSheet = false
var body: some View {
Button("Show Sheet") {
showSheet = true
}
.sheet(isPresented: $showSheet) {
SheetContent()
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
.presentationBackgroundInteraction(.enabled)
.presentationCornerRadius(20)
}
}
}
Key Presentation Modifiers
.presentationDragIndicator()- Shows/hides the drag indicator.presentationBackgroundInteraction()- Allows interaction with content behind the sheet.presentationCornerRadius()- Customizes corner radius.presentationBackground()- Sets custom background.presentationContentInteraction()- Controls scrolling behavior
Creating an Apple Maps-Style Sheet
Here's how to create an interactive sheet similar to the one in Apple Maps:
struct MapsStyleSheet: View {
@State private var showSheet = false
@State private var selectedDetent: PresentationDetent = .height(120)
var body: some View {
ZStack {
// Map view would go here
Color.green.opacity(0.2)
.ignoresSafeArea()
Button("Show Location Details") {
showSheet = true
}
}
.sheet(isPresented: $showSheet) {
LocationDetails()
.presentationDetents(
[.height(120), .medium, .large],
selection: $selectedDetent
)
.presentationBackgroundInteraction(.enabled(upThrough: .medium))
.presentationDragIndicator(.hidden)
.interactiveDismissDisabled(selectedDetent != .height(120))
}
}
}
struct LocationDetails: View {
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
// Compact header
HStack {
VStack(alignment: .leading) {
Text("Apple Park")
.font(.title2)
.bold()
Text("Cupertino, CA")
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer()
Button(action: {}) {
Image(systemName: "phone.fill")
.padding()
.background(Color.blue)
.foregroundColor(.white)
.clipShape(Circle())
}
}
.padding()
Divider()
// More details shown when expanded
VStack(alignment: .leading, spacing: 12) {
Label("Mon-Fri: 9:00 AM - 5:00 PM", systemImage: "clock")
Label("(408) 996-1010", systemImage: "phone")
Label("www.apple.com", systemImage: "link")
}
.padding()
Divider()
// Reviews and photos
Text("Photos & Reviews")
.font(.headline)
.padding(.horizontal)
ScrollView(.horizontal) {
HStack(spacing: 12) {
ForEach(0..<5) { _ in
RoundedRectangle(cornerRadius: 12)
.fill(Color.gray.opacity(0.3))
.frame(width: 200, height: 150)
}
}
.padding(.horizontal)
}
}
}
}
}
Creating a Music Player-Style Sheet
Here's an example inspired by the Now Playing sheet in Apple Music:
struct MusicPlayerSheet: View {
@State private var showPlayer = false
@State private var selectedDetent: PresentationDetent = .height(80)
var body: some View {
Button("Show Now Playing") {
showPlayer = true
}
.sheet(isPresented: $showPlayer) {
NowPlayingView(currentDetent: $selectedDetent)
.presentationDetents(
[.height(80), .large],
selection: $selectedDetent
)
.presentationDragIndicator(.hidden)
.presentationBackgroundInteraction(.enabled(upThrough: .height(80)))
}
}
}
struct NowPlayingView: View {
@Binding var currentDetent: PresentationDetent
var isCompact: Bool {
currentDetent == .height(80)
}
var body: some View {
VStack {
if isCompact {
// Compact mini player
HStack(spacing: 16) {
RoundedRectangle(cornerRadius: 8)
.fill(Color.purple)
.frame(width: 50, height: 50)
VStack(alignment: .leading, spacing: 4) {
Text("Song Title")
.font(.subheadline)
.fontWeight(.semibold)
Text("Artist Name")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Button(action: {}) {
Image(systemName: "play.fill")
}
Button(action: {}) {
Image(systemName: "forward.fill")
}
}
.padding()
} else {
// Full player
VStack(spacing: 24) {
// Drag indicator
Capsule()
.fill(Color.secondary.opacity(0.5))
.frame(width: 40, height: 5)
.padding(.top, 8)
// Album artwork
RoundedRectangle(cornerRadius: 16)
.fill(Color.purple)
.frame(height: 350)
.padding(.horizontal, 40)
// Song info
VStack(spacing: 8) {
Text("Song Title")
.font(.title2)
.fontWeight(.bold)
Text("Artist Name")
.font(.title3)
.foregroundColor(.secondary)
}
// Progress bar
VStack(spacing: 8) {
Slider(value: .constant(0.3))
HStack {
Text("1:23")
.font(.caption)
.foregroundColor(.secondary)
Spacer()
Text("3:45")
.font(.caption)
.foregroundColor(.secondary)
}
}
.padding(.horizontal)
// Controls
HStack(spacing: 40) {
Button(action: {}) {
Image(systemName: "backward.fill")
.font(.title)
}
Button(action: {}) {
Image(systemName: "play.circle.fill")
.font(.system(size: 64))
}
Button(action: {}) {
Image(systemName: "forward.fill")
.font(.title)
}
}
.padding(.top)
Spacer()
}
.padding()
}
}
}
}
Best Practices
Choose Appropriate Detents
Select detents that make sense for your content:
- Use
.height()for fixed content like mini players or action bars - Use
.mediumfor moderate content like forms or details - Use
.fraction()for content that should take a specific portion of the screen - Always include
.largeif users might need full screen access
Provide Visual Feedback
Show drag indicators when multiple detents are available:
.presentationDragIndicator(.visible)
Enable Background Interaction Wisely
For sheets that show contextual information (like location details on a map), allow interaction with the background:
.presentationBackgroundInteraction(.enabled(upThrough: .medium))
Adapt Content to Detent
Design your sheet content to work well at all available detents:
struct AdaptiveContent: View {
@State private var currentDetent: PresentationDetent = .medium
var body: some View {
VStack {
// Always visible header
HeaderView()
// Only show details when not at smallest detent
if currentDetent != .height(100) {
DetailsView()
}
}
.presentationDetents(
[.height(100), .medium, .large],
selection: $currentDetent
)
}
}
Consider Safe Areas
Account for safe areas, especially on devices with notches or dynamic islands:
struct SheetContent: View {
var body: some View {
VStack {
Text("Content")
}
.padding()
.presentationDetents([.medium, .large])
// Extends content into safe area at the bottom
.presentationContentInteraction(.scrolls)
}
}
Common Pitfalls
Setting Only One Non-Large Detent
If you only provide a single non-large detent, users cannot expand the sheet:
// Users cannot expand this sheet
.presentationDetents([.medium])
// Better: Allow expansion
.presentationDetents([.medium, .large])
Forgetting to Handle Content Overflow
Ensure content that exceeds the detent height is scrollable:
struct SheetContent: View {
var body: some View {
ScrollView {
// Long content here
}
.presentationDetents([.medium, .large])
}
}
Inconsistent Detent Ordering
List detents from smallest to largest for predictable behavior:
// Good
.presentationDetents([.height(100), .medium, .large])
// Confusing
.presentationDetents([.large, .height(100), .medium])
Conclusion
The presentationDetents() modifier brings powerful sheet customization to SwiftUI, enabling you to create sophisticated, interactive bottom sheets that feel at home in iOS. Whether you're building a maps app with location details, a music player with a mini player mode, or any interface that benefits from flexible sheet heights, presentation detents provide the control you need.
Key takeaways:
- Use built-in detents (
.medium,.large) for standard heights - Create custom detents with
.fraction()or.height()for specific requirements - Track active detents with selection binding to adapt your UI
- Combine with other presentation modifiers for a polished experience
- Design content that works well at all available detent heights
With presentation detents, you can create sheet-based interfaces that feel natural, responsive, and aligned with modern iOS design patterns.
Continue Learning
Prevent Drag-to-Dismiss on SwiftUI Sheets
Learn how to prevent users from dismissing modal sheets by swiping down in SwiftUI using interactiveDismissDisabled.
Building a Floating Action Button (FAB) that Respects Keyboard in SwiftUI
Learn how to create a Material Design-style floating action button in SwiftUI that intelligently moves above the keyboard and avoids blocking text input fields.
Creating an iMessage-Style Input Accessory View in SwiftUI
Learn how to create a custom input accessory view that stays above the keyboard, similar to iMessage's input bar.
