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.

0:00
/0:16

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:

0:00
/0:15

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!

String Catalog

String Catalog - App Localization on Autopilot

Push to GitHub, and we'll automatically localize your app for 40+ languages, saving you hours of manual work.

Tagged in: