In iOS, How to Drag Down to Dismiss a Modal

Drag to dismiss a UIPresentationController

As your description about the dragging experience you wanted is not that clear, hope I didn't get you wrong.

I'm trying to have the drag feel natural and responsive like the drag experience for "Top Stories" on the Apple iOS 13 stocks app.

What I get is, you want to be able to drag the presented view, dismiss it if it reach certain point, else go back to its original position (and of coz you can bring the view to any position you wanted).
To achieve this, we can add a UIPanGesture to the presentedViewController, then

  1. move the presentedView according to the gesture

  2. dismiss / move back the presentedView

     class SlideUpPresentationController: UIPresentationController {
    // MARK: - Variables
    private var dimmingView: UIView!
    private var originalX: CGFloat = 0

    //MARK: - View functions
    override init(presentedViewController: UIViewController, presenting presentingViewController: UIViewController?) {
    super.init(presentedViewController: presentedViewController, presenting: presentingViewController)
    setupDimmingView()
    }

    override func containerViewWillLayoutSubviews() {
    presentedView?.frame = frameOfPresentedViewInContainerView
    }

    override var frameOfPresentedViewInContainerView: CGRect {
    guard let container = containerView else { return super.frameOfPresentedViewInContainerView }
    let width = container.bounds.size.width
    let height : CGFloat = 300.0

    return CGRect(x: 0, y: container.bounds.size.height - height, width: width, height: height)
    }

    override func presentationTransitionWillBegin() {
    guard let dimmingView = dimmingView else { return }

    containerView?.insertSubview(dimmingView, at: 0)
    // add PanGestureRecognizer for dragging the presented view controller
    let viewPan = UIPanGestureRecognizer(target: self, action: #selector(viewPanned(_:)))
    containerView?.addGestureRecognizer(viewPan)

    NSLayoutConstraint.activate(NSLayoutConstraint.constraints(withVisualFormat: "V:|[dimmingView]|", options: [], metrics: nil, views: ["dimmingView": dimmingView]))

    NSLayoutConstraint.activate(NSLayoutConstraint.constraints(withVisualFormat: "H:|[dimmingView]|", options: [], metrics: nil, views: ["dimmingView": dimmingView]))

    guard let coordinator = presentedViewController.transitionCoordinator else {
    dimmingView.alpha = 1.0
    return
    }

    coordinator.animate(alongsideTransition: { _ in
    self.dimmingView.alpha = 1.0
    })
    }

    @objc private func viewPanned(_ sender: UIPanGestureRecognizer) {
    // how far the pan gesture translated
    let translate = sender.translation(in: self.presentedView)
    switch sender.state {
    case .began:
    originalX = presentedViewController.view.frame.origin.x
    case .changed:
    // move the presentedView according to pan gesture
    // prevent it from moving too far to the right
    if originalX + translate.x < 0 {
    presentedViewController.view.frame.origin.x = originalX + translate.x
    }
    case .ended:
    let presentedViewWidth = presentedViewController.view.frame.width
    let newX = presentedViewController.view.frame.origin.x

    // if the presentedView move more than 0.75 of the presentedView's width, dimiss it, else bring it back to original position
    if presentedViewWidth * 0.75 + newX > 0 {
    setBackToOriginalPosition()
    } else {
    moveAndDismissPresentedView()
    }
    default:
    break
    }
    }

    private func setBackToOriginalPosition() {
    // ensure no pending layout change in presentedView
    presentedViewController.view.layoutIfNeeded()
    UIView.animate(withDuration: 0.25, delay: 0.0, options: .curveEaseIn, animations: {
    self.presentedViewController.view.frame.origin.x = self.originalX
    self.presentedViewController.view.layoutIfNeeded()
    }, completion: nil)
    }

    private func moveAndDismissPresentedView() {
    // ensure no pending layout change in presentedView
    presentedViewController.view.layoutIfNeeded()
    UIView.animate(withDuration: 0.25, delay: 0.0, options: .curveEaseIn, animations: {
    self.presentedViewController.view.frame.origin.x = -self.presentedViewController.view.frame.width
    self.presentedViewController.view.layoutIfNeeded()
    }, completion: { _ in
    // dimiss when the view is completely move outside the screen
    self.presentingViewController.dismiss(animated: true, completion: nil)
    })
    }

    override func dismissalTransitionWillBegin() {
    guard let coordinator = presentedViewController.transitionCoordinator else {
    dimmingView.alpha = 0.0
    return
    }

    coordinator.animate(alongsideTransition: { _ in
    self.dimmingView.alpha = 0.0
    })
    }

    func setupDimmingView() {
    dimmingView = UIView()
    dimmingView.translatesAutoresizingMaskIntoConstraints = false
    dimmingView.backgroundColor = UIColor(white: 0.0, alpha: 0.5)
    dimmingView.alpha = 0.0

    let recognizer = UITapGestureRecognizer(target: self,
    action: #selector(handleTap(recognizer:)))
    dimmingView.addGestureRecognizer(recognizer)
    }

    @objc func handleTap(recognizer: UITapGestureRecognizer) {
    presentingViewController.dismiss(animated: true)
    }

    }

The above code is just an example based on the code you provide, but I hope that explain what's happening under the hood of what you called a drag experience. Hope this helps ;)

Here is the example result:

via GIPHY

Catch when user drags down modal in iOS Swift

As described in Apple's docs, you can implement UIAdaptivePresentationControllerDelegate and use presentationControllerDidAttemptToDismiss(_ :) to "intercept" the pull-down action.

Here is a bare-bones example:

class ConfirmDismissViewController: UIViewController, UIAdaptivePresentationControllerDelegate {

override func viewDidLoad() {
super.viewDidLoad()

view.backgroundColor = .systemYellow

// add a dismiss button
let b = UIButton(type: .system)
b.setTitle("Dismiss", for: [])
b.addTarget(self, action: #selector(btnTapped(_:)), for: .touchUpInside)
b.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(b)
NSLayoutConstraint.activate([
b.centerXAnchor.constraint(equalTo: view.centerXAnchor),
b.centerYAnchor.constraint(equalTo: view.centerYAnchor),
])

// this will trigger a call to presentationControllerDidAttemptToDismiss() on drag-down
isModalInPresentation = true

presentationController?.delegate = self
}

func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController) {

let alert = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet)

// Only ask if the user wants to save if they attempt to pull to dismiss, not if they tap Cancel.
alert.addAction(UIAlertAction(title: "Discard Changes", style: .destructive) { _ in
self.dismiss(animated: true, completion: nil)
})

alert.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil))

present(alert, animated: true, completion: nil)

}

@objc func btnTapped(_ sender: Any?) -> Void {
// dismiss WITHOUT prompt
dismiss(animated: true, completion: nil)
}

}

When you present this view controller, the user can tap the "Dismiss" button to explicitly dismiss the VC, or, if the user drags-down, you'll be prompted.

How can I implement a pull down to dismiss a modal view in SwiftUI?

UIImagePickerController overlaps entire sheet's area and does not allow to handle drag, so, as a workaround, a possible approach could be to add some kind of notch (concept is recognizable so the usability will be clear)

Here is a demo (you can construct style/background according to your app design)

VStack(spacing: 0) {
RoundedRectangle(cornerRadius: 2)
.frame(width: 60, height: 4).foregroundColor(.gray)
.padding(.vertical, 4)
ImagePicker(sourceType: .photoLibrary)
}

Disable gesture to pull down form/page sheet modal presentation

In general, you shouldn't try to disable the swipe to dismiss functionality, as users expect all form/page sheets to behave the same across all apps. Instead, you may want to consider using a full-screen presentation style. If you do want to use a sheet that can't be dismissed via swipe, set isModalInPresentation = true, but note this still allows the sheet to be pulled down vertically and it'll bounce back up upon releasing the touch. Check out the UIAdaptivePresentationControllerDelegate documentation to react when the user tries to dismiss it via swipe, among other actions.

If you have a scenario where your app's gesture or touch handling is impacted by the swipe to dismiss feature, I did receive some advice from an Apple engineer on how to fix that.

If you can prevent the system's pan gesture recognizer from beginning, this will prevent the gestural dismissal. A few ways to do this:

  1. If your canvas drawing is done with a gesture recognizer, such as your own UIGestureRecognizer subclass, enter the began phase before the sheet’s dismiss gesture does. If you recognize as quickly as UIPanGestureRecognizer, you will win, and the sheet’s dismiss gesture will be subverted.

  2. If your canvas drawing is done with a gesture recognizer, setup a dynamic failure requirement with -shouldBeRequiredToFailByGestureRecognizer: (or the related delegate method), where you return NO if the passed in gesture recognizer is a UIPanGestureRecognizer.

  3. If your canvas drawing is done with manual touch handling (e.g. touchesBegan:), override -gestureRecognizerShouldBegin on your touch handling view, and return NO if the passed in gesture recognizer is a UIPanGestureRecognizer.

With my setup #3 proved to work very well. This allows the user to swipe down anywhere outside of the drawing canvas to dismiss (like the nav bar), while allowing the user to draw without moving the sheet, just as one would expect.

I cannot recommend trying to find the gesture to disable it, as it seems to be rather dynamic and can reenable itself when switching between different size classes for example, and this could change in future releases.

Drag down to dismiss a ViewController

These view controller transitions are, sigh, unfortunately very complicated to do but there are lots of good tutorials online about them. You are correct in putting the subview into its own view controller. There are several other moving parts required. Fully detailing them would be enough material to write a book on the topic, but the high level overview is this:

Chapter 1: The Container

Think of the view controller with the map view as view controller A. Think of the slide-up/down view as view controller B. We want to transition seamlessly from A to B (and B to A). Cocoa provides an API for doing this, but first you'll need a container view controller that contains both A and B as a child view controller. This could be a navigation controller or your own custom container controller.

Chapter 2: Noninteractive Custom Container View Controller Transition

Once you have the container view controller, code it up so that you can go from A to B and back by pressing a button. (Throw a debug button somewhere into your app.) So you tap a button and you go from A to B and then you tap it again to go from B to A. To do this, there are the noninteractive custom container view controller transition APIs (it's a mouthful). This will require implementing an animation controller and a transitioning delegate, as detailed in that excellent objc.io tutorial and its corresponding GitHub repository.

Chapter 3: Interactive Custom Container View Controller Transition

But, of course, you want users to swipe up and down to trigger the transition. This will take you into the world of interactive blah blah blah APIs, where you implement an interaction controller and connect it to a pan gesture recognizer. The corresponding GitHub repository to that blog post actually uses a pan gesture like you would. This is particularly finicky work, as you would want the transition to be canceled should the user decide to stop panning midway through, but all that is detailed in the code and article.

Chapter 4: View Sharing

I'm actually not sure what this is called. The problem here is that you want the Google Maps / Spotify look and feel where a part of view controller B's view actually is visible while you're in view controller A. And the pan gesture recognizer is attached to B's view so that when you slide it up, B's view gets bigger and bigger until the interactive animation finishes and you've transitioned from A to B. Is there a good tutorial on this? I haven't found one, but it's becoming more and more common as an iOS UX idiom.

I myself have a small demo project illustrating how to do this. What I do is I have a navigation controller as my container view controller. The navigation controller's root view controller is my home view controller (view controller A). In view controller, I have view controller B as a child. So that's navigation controller owns A owns B.

When the user taps on B, I push B onto the navigation stack, thus reparenting it. At that point, my noninteractive animation controller kicks in as a custom navigation controller push animation. And so it looks like B is smoothly transmogrifying from its small bounds to its larger bounds. (I recommend running the xcodeproj to see the effect as I have done a terrible job of describing it.)

This technique would work just as well for an interactive animation. As to whether it's the best technique, I think that's up for debate — there are lots of ways of doing what you see in the Google Maps and Spotify apps. Apple could certainly do a lot better job of documenting the APIs for complex, interactive animations.

I would caution you to avoid using Auto Layout until you have your animations tweaked and ready, as it doesn't really interact well with your animation controllers. Good ol' frame rectangle math is best here until you find your bearings. Auto Layout is magic and, as you might be able to tell from how exhaustingly long this answer is, there's already enough magic to go around.

See Also

  • Session 803 from WWDC 2015: Designing with Animation

  • Session 214 from WWDC 2014: View Controller Advancements

  • Session 218 from WWDC 2013: Custom Transitions Using View Controllers

You can click on the purple Apple logo in the top right of asciiwwdc.com pages to watch the videos of the talks or grab the PDFs from the Resources tabs. Some of the best documentation for iOS is locked up in these WWDC talks.



Related Topics



Leave a reply



Submit