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
- Measure: Use
GeometryReaderwithin the content view’s.backgroundto read the actual rendered height. - Pass: Store the read height in an
@Statevariable. - Apply: Pass this height to the modifier
.presentationDetents([.height(currentHeight)]). - Refresh: Ensure layout updates trigger animations correctly when content changes.
Complete Code Example
You can copy the following modifier directly into your project:
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.
// Pseudo-code example
ViewThatFits(in: .vertical) {
Content() // Try to show all content natively
ScrollView { Content() } // Fallback to ScrollView if space is insufficient
}