🔍

SwiftUI Sheet Auto-Sizing: Dynamic Height Based on Content

(Updated on )

TL;DR: Native SwiftUI presentationDetents typically only support fixed values like .medium or .large. To create a Sheet where the height is strictly determined by its content, we need to combine GeometryReader to measure dimensions dynamically and pass the calculated height to the .height() modifier.

In SwiftUI development, creating a “Sheet” that automatically adjusts its height to fit its content (Auto-sizing) is a common requirement. While iOS 16 introduced presentationDetents, it surprisingly lacks a native “fitContent” option. This article explains how to achieve this effect through a simple wrapper and how to solve animation flickering issues during height changes.

Core Logic

  1. Measure: Use GeometryReader within the content view’s .background to read the actual rendered height.
  2. Pass: Store the read height in an @State variable.
  3. Apply: Pass this height to the modifier .presentationDetents([.height(currentHeight)]).
  4. Refresh: Ensure layout updates trigger animations correctly when content changes.

Complete Code Example

You can copy the following modifier directly into your project:

Swift
extension View {
  func adaptiveSheet<Content: View>(isPresent: Binding<Bool>, @ViewBuilder sheetContent: () -> Content) -> some View {
    modifier(AdaptiveSheetModifier(isPresented: isPresent, sheetContent))
  }
}

struct AdaptiveSheetModifier<SheetContent: View>: ViewModifier {
  @Binding var isPresented: Bool
  @State private var subHeight: CGFloat = 0
  var sheetContent: SheetContent

  init(isPresented: Binding<Bool>, @ViewBuilder _ content: () -> SheetContent) {
    _isPresented = isPresented
    sheetContent = content()
  }

  func body(content: Content) -> some View {
    content
      .background(
        sheetContent
          .background(
            GeometryReader { proxy in
              Color.clear
                .task(id: proxy.size.height) {
                  subHeight = proxy.size.height
                }
            }
          )
          .hidden()
      )
      .sheet(isPresented: $isPresented) {
        sheetContent
          .presentationDetents([.height(subHeight)])
      }
      .id(subHeight)
  }
}

struct ContentView: View {
  @State var show = false
  @State var height: CGFloat = 250
  var body: some View {
    List {
      Button("Pop Sheet") {
        height = 250
        show.toggle()
      }
      Button("Pop ScrollView Sheet") {
        height = 1000
        show.toggle()
      }
    }
    .adaptiveSheet(isPresent: $show) {
      ViewThatFits(in: .vertical) {
        SheetView(height: height)
        ScrollView {
          SheetView(height: height)
        }
      }
    }
  }
}

struct SheetView: View {
  let height: CGFloat
  var body: some View {
    Text("Hi")
      .frame(maxWidth: .infinity, minHeight: height)
      .presentationBackground(.orange)
  }
}

Technical Deep Dive

1. Eliminating Flickering

When a Sheet first expands, it takes a single frame to calculate the content height. This can sometimes cause a visual “jump” where the sheet appears at full height before snapping to the correct size. Solution: While the rendering pipeline in Swift 6 / iOS 26 is highly optimized, a common trick is to provide an initial estimated height or temporarily hide the content (opacity(0)) until the first calculation is complete. However, binding .height directly as shown above usually performs well in modern iOS versions.

2. Handling Dynamic Content Updates

If the Sheet is already open and the content inside changes (e.g., the user taps “Show More”), the Sheet needs to grow or shrink smoothly. This requires withAnimation inside the onPreferenceChange closure. Without it, presentationDetents might snap instantly to the new value instead of animating, or it might lag behind the content update.

3. Combining with ViewThatFits

For highly dynamic content that might exceed the screen height on smaller devices, it is recommended to combine this with ViewThatFits. This ensures that if the content is too tall, it falls back to a .large (full screen) scrolling mode to prevent truncation.

Swift
// Pseudo-code example
ViewThatFits(in: .vertical) {
    Content() // Try to show all content natively
    ScrollView { Content() } // Fallback to ScrollView if space is insufficient
}

Further Reading

Related Tips

Subscribe to Fatbobman

Weekly Swift & SwiftUI highlights. Join developers.

Subscribe Now