Skip to content

How to create resizable sheets in SwiftUI | SwiftUI Bootcamp #64

Apps like Maps and Shortcuts use sheets that snap to different heights — a small peek, a half-screen view, and full screen — giving users control over how much context they see at once. presentationDetents is the iOS 16 API that makes this pattern trivially easy to implement in SwiftUI.

What You'll Learn

  • How to define multiple snap points for a sheet using presentationDetents
  • The difference between .medium, .large, .fraction(_:), and .height(_:) detents
  • How to programmatically change the sheet's current height from inside the sheet itself using a @Binding

Mental Model

Imagine a window blind you can pull down to different preset positions: fully up (hidden), a quarter down (a peek), halfway (medium), and fully down (full screen). Each position is a "detent" — a preset stop that the blind snaps to. presentationDetents works the same way: you declare a set of stops, and the user can drag the sheet between them. When the user lets go, the sheet snaps to the nearest stop.

The selection parameter is the blind's pull cord in your hand — by holding it, you can pull the blind to any preset position from code, not just from the user dragging.

Detailed Explanation

presentationDetents(_:) is an iOS 16 modifier applied to the content view inside a .sheet. It accepts a Set of PresentationDetent values — you declare all the stops the sheet can snap to, and the user can drag between them. If you provide only one detent (e.g., .height(500)), the sheet is fixed at that height and cannot be dragged.

The four detent types serve different needs: .medium snaps to approximately half the screen height (the exact value is system-controlled); .large is full height; .fraction(0.2) is 20% of the available height; .height(600) is an absolute 600-point height. Using .fraction is more adaptive across different screen sizes than hard-coded .height values.

The selection binding is the secret to programmatic control. When you pass selection: $detents, the sheet reflects the currently active detent. Your code can write to detents to animate the sheet to a different height — this is how the buttons inside MyNextView work. The binding flows from the parent (which owns detents) to the sheet content (which can modify it).

presentationDragIndicator(.hidden) removes the small handle at the top of the sheet. Use this when you want full control of dismiss behavior and don't want to suggest the sheet is draggable. .interactiveDismissDisabled() (commented out) prevents the user from swiping the sheet away entirely.

Code Structure

The sample is split into two views: ResizableSheetBootcamp presents the sheet and owns the detents state, and MyNextView lives inside the sheet and contains buttons that change the detent. This parent-child pattern using @Binding is the correct way to give the sheet content control over its own size.

Complete Code

ResizableSheetBootcamp.swift

swift
import SwiftUI

struct ResizableSheetBootcamp: View {
    
    @State private var showSheet: Bool = false
    @State private var detents: PresentationDetent = .large // Tracks the current active detent
    
    var body: some View {
        Button("Click me!") {
            showSheet.toggle()
        }
        .sheet(isPresented: $showSheet) {
            MyNextView(detents: $detents) // Pass detents as a binding so the sheet can change its own height
//                .presentationDetents([.medium, .large])
//                .presentationDetents([.fraction(0.1), .height(300), .medium, .large])
//                .presentationDetents([.height(500)])
//                .presentationDetents([.fraction(0.5), .medium, .large], selection: $detents)
//                .presentationDragIndicator(.hidden)
//                .interactiveDismissDisabled()
        }
//        .onAppear {
//            showSheet = true
//        }
    }
}

struct MyNextView: View {
    
    @Binding var detents: PresentationDetent // Receives the binding to control the sheet height
    
    var body: some View {
        ZStack {
            Color.red.ignoresSafeArea()
            
            VStack(spacing: 20) {
                Button("20%") {
                    detents = .fraction(0.2) // Snaps sheet to 20% of screen height
                }
                
                Button("MEDIUM") {
                    detents = .medium // Snaps sheet to system-defined medium height (~50%)
                }
                
                Button("600 PX") {
                    detents = .height(600) // Snaps sheet to absolute 600-point height
                }
                
                Button("LARGE") {
                    detents = .large // Expands sheet to full height
                }
            }
        }
        // All four valid detents must be declared here so the sheet can snap to them
        .presentationDetents([.fraction(0.2), .medium, .height(600), .large], selection: $detents)
        .presentationDragIndicator(.hidden) // Hides the drag handle at the top of the sheet
    }
}

struct ResizableSheetBootcamp_Previews: PreviewProvider {
    static var previews: some View {
        ResizableSheetBootcamp()
    }
}

Code Walkthrough

  1. @State private var detents: PresentationDetent = .large — The parent view owns the active detent value. Starting at .large means the sheet opens fully expanded. This state must live in the parent because it persists across sheet opens and closes.

  2. MyNextView(detents: $detents) — The dollar-sign passes a Binding to the selected PresentationDetent to the sheet content. Now both the parent and the sheet content can read and write the same detent value.

  3. @Binding var detents: PresentationDetent — Inside the sheet, @Binding receives the binding from the parent. Writing detents = .medium inside a button action updates the parent's state and causes the sheet to animate to the medium detent.

  4. .presentationDetents([.fraction(0.2), .medium, .height(600), .large], selection: $detents) — This is the critical modifier. The set defines the valid stop positions. The selection parameter keeps detents synchronized with the user's drag position. If you pass selection but omit a value from the detent set, the sheet will never snap there.

  5. .presentationDragIndicator(.hidden) — Removes the visual handle. This is appropriate here because the sheet has its own buttons for changing height, so the handle would be redundant. Without this modifier, a pill-shaped indicator appears at the top.

Common Mistakes

Mistake: Applying .presentationDetents on the sheet modifier, not on the sheet's content view
.presentationDetents must be applied to the view inside the .sheet closure, not to the view that presents the sheet. It's a presentation modifier like .navigationTitle — it must be on the content being presented.

Mistake: Including a detent in selection that isn't in the detents set
If you initialize detents = .height(900) but your presentationDetents([...]) set doesn't include .height(900), the sheet won't know how to handle that value and may behave unexpectedly. Every value you might assign to detents must appear in the detents set.

Mistake: Driving multiple independent sheets with unrelated Booleans
Using separate @State var showSheetA, showSheetB, showSheetC booleans to control multiple sheets is fragile — two can theoretically become true at once. Instead, drive presentation from a single @State var activeSheet: SheetType? enum that is nil when no sheet is shown.

Key Takeaways

  • presentationDetents requires iOS 16+ and defines the snap points a sheet can rest at; the sheet freely animates between them on drag
  • Pass selection: $someBinding to programmatically move the sheet between detents from code
  • The sheet's content view owns .presentationDetents — it's a presentation modifier on the presented content, not on the presenter

Last updated: June 27, 2026

Released under the MIT License.