avatar
Published on

Control Sheet Height with presentationDetents in SwiftUI

Authors
  • avatar
    Name
    Mick MacCallum
    Twitter
    @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 .medium for moderate content like forms or details
  • Use .fraction() for content that should take a specific portion of the screen
  • Always include .large if 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.