Summary
We encountered a regression in UI behavior when attempting to stack multiple SwiftUI sheets using custom presentation detents. While the system-provided .medium() and .large() detents behave predictably, applying custom height values causes a synchronization error. Specifically, when the second sheet is presented, the first sheet incorrectly resizes its height to match the second sheet, leading to visual occlusion where the paywall covers the content it was intended to supplement.
Root Cause
The issue stems from how the SwiftUI UISheetPresentationController (the underlying UIKit implementation) manages the presentation context when custom detents are injected.
- Detent Synchronization: When using system detents, SwiftUI maintains a strict hierarchy of available heights. When custom detents are used, the internal layout engine fails to differentiate the height constraints of the parent sheet from the child sheet.
- Constraint Overwriting: The presentation of the second sheet triggers a layout pass. Because the custom detents do not follow the standard internal sizing logic of the system presets, the parent sheet adopts the target height of the newly presented sheet to ensure “smooth” transitions.
- State Conflict: The
@Statedriven presentation logic assumes that sheets are independent layers, but the layout engine treats custom-height sheets as interdependent containers when stacked.
Why This Happens in Real Systems
In complex UI frameworks, abstraction layers often hide the underlying imperative logic.
- Abstraction Leaks: SwiftUI abstracts away
UISheetPresentationController. When we move from “standard” values (which have highly optimized, hardcoded behaviors) to “custom” values, we fall into an unoptimized code path. - Layout Engine Heuristics: UI frameworks often use heuristics to guess how a window should resize during animations. If the framework sees two sheets with custom constraints, it may attempt to normalize their heights to prevent visual “jitter” during the transition, inadvertently causing the bug.
Real-World Impact
- Broken User Experience: Users cannot see the context behind the paywall, which is critical for conversion (e.g., seeing the feature they are about to unlock).
- UI Layering Failures: The visual hierarchy is destroyed, making the application look unpolished or “broken.”
- Conversion Drop-off: In the specific case of a paywall, if the sheet covering the content hides the very value proposition the user is looking at, the business suffers direct revenue loss.
Example or Code
import SwiftUI
struct ContentView: View {
@State private var showingFirst = false
@State private var showingSecond = false
// Custom heights in pixels/points
let firstSheetHeight = 300.0
let secondSheetHeight = 200.0
var body: some View {
VStack {
Button("Show First Sheet") {
showingFirst = true
}
}
.sheet(isPresented: $showingFirst) {
VStack {
Text("Primary Content")
Button("Show Paywall") {
showingSecond = true
}
}
// The bug occurs here when using custom detents
.presentationDetents([.height(firstSheetHeight)])
.sheet(isPresented: $showingSecond) {
Text("Paywall Content")
.presentationDetents([.height(secondSheetHeight)])
}
}
}
}
How Senior Engineers Fix It
When the built-in component (the Sheet) fails to respect the required layout constraints, a senior engineer shifts from declarative convenience to structural intentionality.
- Component Substitution: Instead of fighting the
Sheetbehavior, replace the second sheet with a custom overlay or ZStack view. This provides full control over the Z-index and height without triggering the UIKit sheet-stacking logic. - View Composition: Use a
.fullScreenCoverfor the first layer if visibility is required, or wrap the entire view hierarchy in a container that manages its own “pop-up” state. - Manual Detent Management: If sheets are mandatory, implement a custom transition engine using
GeometryReaderto simulate sheet behavior without relying on the buggypresentationDetentsimplementation for custom values.
Why Juniors Miss It
- Reliance on “Magic” APIs: Juniors often assume that if an API exists (like
.presentationDetents), it is bug-free and will always work for any input. - Symptom-only Debugging: A junior might try to fix this by adding more
.presentationDetentsor changing the order of modifiers, rather than recognizing that the underlying component is fundamentally unsuitable for the specific requirement. - Ignoring the Hierarchy: Juniors often focus on the
Viewbeing presented, while seniors focus on the Presentation Context (the parent sheet) and how the transition affects the global UI state.