iosswiftdraggableuipangesturerecognizeruipresentationcontroller

Drag to dismiss a UIPresentationController


I have made a UIPresentationController that fits any view controller and shows up on half of the screen using this tutorial. Now I would love to add drag to dismiss to this. 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. I thought the iOS 13 modal drag to dismiss would get carried over but it doesn't to this controller but it doesn't.

Every bit of code and tutorial I found had a bad dragging experience. Does anyone know how to do this? I've been trying / searching for the past week. Thank you in advance

Here's my code for the presentation controller

class SlideUpPresentationController: UIPresentationController {
    // MARK: - Variables
    private var dimmingView: UIView!
    
    //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)
      
      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
      })
    }
    
    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)
    }
}

Solution

  • 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