'Is there a way to have interactive modal dismissal for a .fullscreen modally presented view controller?

I want to enable interactive modal dismissal that pans along with a users finger on a fullscreen modally presented view controller .fullscreen. I've seen that it's fairly trivial to do so on the .pageSheet and the .formSheet which have it built in but have not seen a clear example for the full screen. I'm guessing I'd need to have a pan gesture added to my vc within the body of it's code and then adjust for the states myself but wondering if anyone knows what exactly needs to be done / if there's a simpler way to do it as it seems much more complicated for the .fullscreen case



Solution 1:[1]

It can be done with creating your custom UIPresentationController and UIViewControllerTransitioningDelegate. Lets say we have TestViewController and we want to present SecondViewController with total presentedHeight of 1.0 (fullScreen). Presentation will be triggered with @IBAction func buttonPressed and can be dismissed by dragging controller down (as we are used to it). It would be also nice to add some backgroundEffect to be gradually changed while user is sliding down the SecondViewController (especially when used only presentedHeight of 0.6).

  1. Firstly we define OverlayViewController which will be later superclass of presented SecondViewControllerand will contain UIPanGestureRecognizer.

       class OverlayViewController: UIViewController {
    
       var hasSetPointOrigin = false
       var pointOrigin: CGPoint?
       var delegate: OverlayViewDelegate?
    
       override func viewDidLoad() {
        super.viewDidLoad()
        let panGesture = UIPanGestureRecognizer(target: self, action: #selector(panGestureRecognizerAction))
        view.addGestureRecognizer(panGesture)
    
       }
    
       override func viewDidLayoutSubviews() {
        if !hasSetPointOrigin {
         hasSetPointOrigin = true
         pointOrigin = self.view.frame.origin
        }
       }
       @objc func panGestureRecognizerAction(sender: UIPanGestureRecognizer) {
        let translation = sender.translation(in: view)
    
     // Not allowing the user to drag the view upward
     guard translation.y >= 0 else { return }
     let currentPosition = translation.y
     let originPos = self.pointOrigin
     delegate?.userDragged(draggedPercentage: translation.y/originPos!.y)
    
     // setting x as 0 because we don't want users to move the frame side ways!! Only want straight up or down
     view.frame.origin = CGPoint(x: 0, y: self.pointOrigin!.y + translation.y)
    
     if sender.state == .ended {
         let dragVelocity = sender.velocity(in: view)
         if dragVelocity.y >= 1100 {
             self.dismiss(animated: true, completion: nil)
         } else {
             // Set back to original position of the view controller
             UIView.animate(withDuration: 0.3) {
                 self.view.frame.origin = self.pointOrigin ?? CGPoint(x: 0, y: 400)
                 self.delegate?.animateBlurBack(seconds: 0.3)
             }
         }
       }
      }
     }
    
     protocol OverlayViewDelegate: AnyObject {
     func userDragged(draggedPercentage: CGFloat)
     func animateBlurBack(seconds: TimeInterval)
     }
    
  2. Next we define custom PresentationController

       class PresentationController: UIPresentationController {
    
         private var backgroundEffectView: UIView?
         private var backgroundEffect: BackgroundEffect?
     private var viewHeight: CGFloat?
     private let maxDim:CGFloat = 0.6
     private var tapGestureRecognizer: UITapGestureRecognizer = UITapGestureRecognizer()
    
     convenience init(presentedViewController: UIViewController,
                      presenting presentingViewController: UIViewController?,
                      backgroundEffect: BackgroundEffect = .blur,
                      viewHeight: CGFloat = 0.6)
     {
    
         self.init(presentedViewController: presentedViewController, presenting: presentingViewController)
    
         self.backgroundEffect = backgroundEffect
         self.backgroundEffectView = returnCorrectEffectView(backgroundEffect)
         self.tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(dismissController))
         self.backgroundEffectView?.autoresizingMask = [.flexibleWidth, .flexibleHeight]
         self.backgroundEffectView?.isUserInteractionEnabled = true
         self.backgroundEffectView?.addGestureRecognizer(tapGestureRecognizer)
         self.viewHeight = viewHeight
     }
    
     private override init(presentedViewController: UIViewController, presenting presentingViewController: UIViewController?) {
         super.init(presentedViewController: presentedViewController, presenting: presentingViewController)
     }
    
     override var frameOfPresentedViewInContainerView: CGRect {
         CGRect(origin: CGPoint(x: 0, y: self.containerView!.frame.height * (1-viewHeight!)),
                size: CGSize(width: self.containerView!.frame.width, height: self.containerView!.frame.height *
                                 viewHeight!))
     }
    
     override func presentationTransitionWillBegin() {
         self.backgroundEffectView?.alpha = 0
         self.containerView?.addSubview(backgroundEffectView!)
         self.presentedViewController.transitionCoordinator?.animate(alongsideTransition: { (UIViewControllerTransitionCoordinatorContext) in
             switch self.backgroundEffect! {
             case .blur:
                 self.backgroundEffectView?.alpha = 1
             case .dim:
                 self.backgroundEffectView?.alpha = self.maxDim
             case .none:
                 self.backgroundEffectView?.alpha = 0
             }
         }, completion: { (UIViewControllerTransitionCoordinatorContext) in })
     }
    
     override func dismissalTransitionWillBegin() {
         self.presentedViewController.transitionCoordinator?.animate(alongsideTransition: { (UIViewControllerTransitionCoordinatorContext) in
             self.backgroundEffectView?.alpha = 0
         }, completion: { (UIViewControllerTransitionCoordinatorContext) in
             self.backgroundEffectView?.removeFromSuperview()
         })
     }
    
     override func containerViewWillLayoutSubviews() {
         super.containerViewWillLayoutSubviews()
     }
    
     override func containerViewDidLayoutSubviews() {
         super.containerViewDidLayoutSubviews()
         presentedView?.frame = frameOfPresentedViewInContainerView
         backgroundEffectView?.frame = containerView!.bounds
     }
    
     @objc func dismissController(){
         self.presentedViewController.dismiss(animated: true, completion: nil)
     }
    
     func graduallyChangeOpacity(withPercentage: CGFloat) {
         self.backgroundEffectView?.alpha = withPercentage
     }
    
     func returnCorrectEffectView(_ effect: BackgroundEffect) -> UIView {
         switch effect {
    
         case .blur:
             var blurEffect = UIBlurEffect(style: .dark)
             if self.traitCollection.userInterfaceStyle == .dark {
                 blurEffect = UIBlurEffect(style: .light)
             }
             return UIVisualEffectView(effect: blurEffect)
         case .dim:
             var dimView = UIView()
             dimView.backgroundColor = .black
             if self.traitCollection.userInterfaceStyle == .dark {
                 dimView.backgroundColor = .gray
             }
             dimView.alpha = maxDim
             return dimView
         case .none:
             let clearView = UIView()
             clearView.backgroundColor = .clear
             return clearView
         }
        }
       }
    
         extension PresentationController: OverlayViewDelegate {
         func userDragged(draggedPercentage: CGFloat) {
         graduallyChangeOpacity(withPercentage: 1-draggedPercentage)
    
         switch self.backgroundEffect! {
         case .blur:
             graduallyChangeOpacity(withPercentage: 1-draggedPercentage)
         case .dim:
             graduallyChangeOpacity(withPercentage: maxDim-draggedPercentage)
         case .none:
             self.backgroundEffectView?.alpha = 0
         }
     }
    
     func animateBlurBack(seconds: TimeInterval) {
         UIView.animate(withDuration: seconds) {
             switch self.backgroundEffect! {
             case .blur:
                 self.backgroundEffectView?.alpha = 1
             case .dim:
                 self.backgroundEffectView?.alpha = self.maxDim
             case .none:
                 self.backgroundEffectView?.alpha = 0
             }
    
         }
       }
      }
    
       enum BackgroundEffect {
        case blur
        case dim
        case none
       }
    
  3. Create SecondViewController subclassing OverlayViewController:

     class SecondViewController: OverlayViewController {
    
     override func viewDidLoad() {
         super.viewDidLoad()
         self.view.backgroundColor = .blue
         // Do any additional setup after loading the view.
     }
    
     override func viewDidLayoutSubviews() {
         super.viewDidLayoutSubviews()
         addSlider()
     }
    
     func addSlider() {
         let sliderWidth:CGFloat = 100
         let centerOfScreen = self.view.frame.size.width / 2
         let rect = CGRect(x: centerOfScreen - sliderWidth/2, y: 80, width: sliderWidth, height: 10)
         let slider = UIView(frame: rect)
         slider.backgroundColor = .black
         self.view.addSubview(slider)
     }
    
  4. Add showOverlay() function that will be triggered after buttonPressed and conform your presenting UIViewController (TestViewController) to UIViewControllerTransitioningDelegate :

     class TestViewController: UIViewController {
    
     override func viewDidLoad() {
         super.viewDidLoad()
    
         // Do any additional setup after loading the view.
     }
    
     @IBAction func buttonPressed(_ sender: Any) {
         showOverlay()
     }
    
     func showOverlay() {
         let secondVC =  UIStoryboard(name: "Main", bundle: nil).instantiateViewController(withIdentifier: "secondVC") as! SecondViewController
         secondVC.modalPresentationStyle = .custom
         secondVC.transitioningDelegate = self
         self.present(secondVC, animated: true, completion: nil)
     }
    }
    
     extension TestViewController: UIViewControllerTransitioningDelegate {
     func presentationController(forPresented presented: UIViewController,
                                 presenting: UIViewController?,
                                 source: UIViewController) -> UIPresentationController?
     {
         let presentedHeight: CGFloat = 1.0
         let controller = PresentationController(presentedViewController: presented,
                                                 presenting: presenting,
                                                 backgroundEffect: .dim,
                                                 viewHeight: presentedHeight)
    
         if let vc = presented as? OverlayViewController {
             vc.delegate = controller
         }
         return controller
      }
     }
    
  5. Now we should be able to present SecondViewController with showOverlay() function setting its presentedHeight to 1.0 and .dim background effect. We can dismiss SecondViewController similar to another modal presentations.

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 Mr.SwiftOak