I recently wrote about bottom sheets in SwiftUI while working on my Meshing app. I realized I had been experimenting with hardcoded fractions to control when to show "detailed" controls. It felt clunky and inflexible. Also, I needed more control over the bottom sheet, especially the presentation detents detection.
I randomly stumbled upon another modifier for it: presentationDetents(_ detents: Set<PresentationDetent>, selection: Binding<PresentationDetent>)
and excited to share what I have learned. Let's explore how to detect and control bottom sheet positions in SwiftUI!
Creating an Enum for Sheet Positions
I like enumerations, so I created a custom enum for sheet positions. This approach keeps the code clean and makes it easy to adjust fraction values in one place:
enum SheetPosition: CGFloat, CaseIterable {
case peek = 0.1
case detailed = 0.5
var detent: PresentationDetent {
.fraction(rawValue)
}
static let detents = Set(SheetPosition.allCases.map { $0.detent })
}
In this enum, peek
represents the minimized sheet (10% of the screen height), and detailed
is the expanded view (50% of the screen height). The detent
computed property converts the cases to PresentationDetent
values.
Implementing the Selection Modifier
With the enum in place, I used the newly discovered presentationDetents(_:selection:)
modifier to control the sheet position. This modifier gave me programmatic control over the currently selected detent:
@State private var selectedDetent: PresentationDetent = SheetPosition.peek.detent
.sheet(isPresented: .constant(true)) {
ControlPanel(viewModel: viewModel, showDetailedControls: $showDetailedControls)
.presentationDetents(SheetPosition.detents,
selection: $selectedDetent
)
.presentationDragIndicator(.visible)
}
I have the static allDetents
set and binding the selection to a selectedDetent
state variable.
Listening for Position Changes
To respond to changes in the sheet position, I use the onChange
modifier to update the UI based on the selected detent. I ran into a tricky problem where the selectedDetent
binding updated and refreshed the ControlPanel
view, it interfered with the actual detent change. This led to a situation where showDetailedControls
would update, but the sheet position would not, resulting in a mismatched view state. To fix this, I implemented a small delay:
.onChange(of: selectedDetent) {
Task {
// Add a small delay to ensure the detent change completes
try? await Task.sleep(for: .milliseconds(50))
// Update UI on the main thread
await MainActor.run {
switch selectedDetent {
case SheetPosition.peek.detent:
showDetailedControls = false
case SheetPosition.detailed.detent:
showDetailedControls = true
default: break
}
}
}
}
This delay gives the sheet time to complete its position change before updating showDetailedControls
. By running this in a separate task and updating the UI on the main thread, I ensure smooth transitions without blocking other UI updates.
Adding Smooth Animations
To make the UI changes feel smooth and polished, let's add some animations. I directly apply the animation modifier to the view:
.animation(.easeInOut, value: showDetailedControls)
Updating the Control Panel For Disclosure Groups
In the ControlPanel
view, I added a constant for showDetailedControls
:
struct ControlPanel: View {
@ObservedObject var viewModel: MeshViewModel
let showDetailedControls: Bool
var body: some View {
// Control panel content
}
}
This allows the ControlPanel
to update itself to changes in the sheet position to show the detailed controls. Also, I encountered a small bug where I wanted the disclosure group to close when the sheet was in its peek state:
.onChange(of: showDetailedControls) { oldValue, newValue in
if !newValue {
isTemplatesExpanded = false
}
}
This code closes the templates disclosure group whenever the detailed controls are hidden!
Finally, here is how it looks:
Moving Forward
The discloure group closing is still a bit glitchy but I have invested (or wasted) enough time that it is time to move forward with what I have.
I hope you find it as useful as I have! Feel free to adapt them to your own projects and let me know how it goes. Happy coding!