Exploring SwiftUI: DragGesture for fullScreenCover

When working with SwiftUI, you are familiar with the sheet(isPresented:onDismiss:content:) modifier for presenting views. It is a handy way to display content in a modal-style overlay, and it comes with a built-in swipe gesture to dismiss the view.

But what if you want to use a fullScreenCover(isPresented:onDismiss:content:) instead? Unfortunately, it does not have that same swipe-to-dismiss functionality out of the box. With a little workaround, we can add it ourselves using DragGesture!

DragGesture

You can use a DragGesture that calculates the difference between the starting and ending position of the swipe to swipe down on the full-screen view. If the swipe distance exceeds a certain threshold (say 150 points), then you can programmatically dismiss the fullScreenCover.

Here is a simple example of how you might set this up:

struct SampleView: View {
 @State private var showCoverView = false
 
 var body: some View {
   Button("PRESENT") {
     showCoverView.toggle()
   }
   .fullScreenCover(isPresented: $showCoverView) {
     CoverView()
   }
 }
}

struct CoverView: View {
 /// Use @Environment(\.presentationMode) private var presentationMode for iOS 14 and below
 @Environment(\.dismiss) private var dismiss
 
 var body: some View {
   Button("DISMISS") {
     /// Use presentationMode.wrappedValue.dismiss() for iOS 14 and below
     dismiss()
   }
   .gesture(
     DragGesture().onEnded { value in
       if value.location.y - value.startLocation.y > 150 {
         /// Use presentationMode.wrappedValue.dismiss() for iOS 14 and below
         dismiss()
       }
     }
   )
 }
}

The key is the gesture modifier applied in CoverView. This adds a DragGesture that tracks the user's swipe. In the onEnded closure, it checks if the height of the swipe translation exceeds 150 points. If so, it calls dismiss() to close the fullScreenCover.

Example from Chroma Game

Let us take a closer look at a real-world example in my Chroma Game app.

In the HomeView, the view model contains an enum called GameModeDestination that determines which color mode to present - either RGB or HSB. The fullScreenCover modifier uses this enum to decide which view to display.

struct HomeView: View {
  @EnvironmentObject var viewModel: HomeViewModel
  
  var body: some View {
    VStack {
      Text("MAIN VIEW IMPLEMENTATION")
      
      /// Stripped implementation of Home View  
      Button("PRESENT RGB") {
        viewModel.gameDestination = .rgb
      }
    }
    .fullScreenCover(
      item: $viewModel.gameDestination,
      onDismiss: viewModel.didDismiss) { item in
        switch item {
          case .rgb: RGBView(viewModel: viewModel.new)
          case .hsb: HSBView(viewModel: viewModel.new)
        }
    }
  }
}

The presented view (either RGBView or HSBView) is wrapped in a ContainerView. This ContainerView displays a dismiss button at the top, similar to the now playing screen in the Apple Music app. The button uses a custom DismissButton view:

public struct DismissButton: View {
  var action: () -> ()

  public init(_ action: @escaping () -> ()) {
    self.action = action
  }
  
  public var body: some View {
    Button(action: action) {
      RoundedRectangle(cornerRadius: 16)
        .fill(Color.gray)
        .frame(width: 50, height: 5)
    }
  }
}

The ContainerView itself applies the DragGesture to enable the swipe-to-dismiss interaction:

struct ContainerView: View {
  @Environment(\.presentationMode) var presentation
  @ObservedObject var viewModel: MainViewModel
  
  var body: some View {
    VStack {
      DismissButton { dismiss() }
      
      Text("IMPLEMENTATION")
    }
    .gesture(
      DragGesture().onEnded { value in
        if value.location.y - value.startLocation.y > 150 {
          dismiss()
        }
      }
    )
  }
  
  private func dismiss() {
    viewModel.invalidateTimer()
    viewModel.dismiss()
    presentation.wrappedValue.dismiss()
  }
}

When the user swipes down on the ContainerView and the swipe distance exceeds 150 points, the dismiss() function is called. This function updates the view model, invalidates any active timers, and then uses the presentationMode environment variable to dismiss the view.

Conclusion

And that's all there is to it! With just a few lines of code, you can tweak the fullScreenCover experience to support the type of swipe-to-dismiss interaction that many users have come to expect from modal views.

Give it a try in your apps and let me know how it goes! I wouldd love to see the creative ways you put this technique to use. Happy coding!