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!

Astro Affiliate.

Astro (Affiliate)

Find the right keywords for your app and climb the App Store rankings. Improve your app visibility and increase your revenue with Astro. The first App Store Optimization tool designed for indie developers!

Tagged in: