Forked from vinczebalazs/SheetModalPresentationController.swift
Created
August 17, 2022 17:44
-
-
Save V8tr/bb4157dbab3f14b2f96cb8b51a44a6e2 to your computer and use it in GitHub Desktop.
A presentation controller to use for presenting a view controller modally, which can be dismissed by a pull down gesture. The presented view controller's height is also adjustable.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // | |
| // SheetModalPresentationController.swift | |
| // | |
| // | |
| // Created by Balazs Vincze on 2020. 01. 14.. | |
| // Copyright © 2020. Balazs Vincze. All rights reserved. | |
| // | |
| import UIKit | |
| final class SheetModalPresentationController: UIPresentationController { | |
| // MARK: Private Properties | |
| private let height: CGFloat | |
| private let interactor = UIPercentDrivenInteractiveTransition() | |
| private let dimmingView = UIView() | |
| private var propertyAnimator: UIViewPropertyAnimator! | |
| private var isInteractive = false | |
| // MARK: Initializers | |
| init(presentedViewController: UIViewController, presenting presentingViewController: UIViewController?, height: CGFloat) { | |
| self.height = min(height, presentedViewController.view.frame.height) | |
| super.init(presentedViewController: presentedViewController, presenting: presentingViewController) | |
| } | |
| // MARK: Function Overrides | |
| override var frameOfPresentedViewInContainerView: CGRect { | |
| guard let containerBounds = containerView?.bounds else { return .zero } | |
| // Calculate the frame for the presented view controller using the passed in height. | |
| var frame = containerBounds | |
| frame.size.height = height | |
| frame.origin.y = containerBounds.height - height | |
| return frame | |
| } | |
| override func presentationTransitionWillBegin() { | |
| super.presentationTransitionWillBegin() | |
| guard let containerBounds = containerView?.bounds else { return } | |
| // Add a pan gesture recognizer for pull to dismiss. | |
| presentedView?.addGestureRecognizer(UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))) | |
| // Round the the presented view controller's corners. | |
| presentedView?.layer.cornerRadius = 40 | |
| // Add a dimming view below the presented view controller, and a tap gesture recognizer to it for dismissal. | |
| dimmingView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(dismiss))) | |
| dimmingView.backgroundColor = .black | |
| dimmingView.frame = containerBounds | |
| dimmingView.alpha = 0 | |
| containerView?.insertSubview(dimmingView, at: 0) | |
| presentedViewController.transitionCoordinator?.animate(alongsideTransition: { _ in | |
| self.dimmingView.alpha = 0.5 | |
| }) | |
| } | |
| override func dismissalTransitionWillBegin() { | |
| super.dismissalTransitionWillBegin() | |
| presentedViewController.transitionCoordinator?.animate(alongsideTransition: { _ in | |
| self.dimmingView.alpha = 0 | |
| }) | |
| } | |
| // MARK: Private Functions | |
| @objc private func dismiss() { | |
| presentedViewController.dismiss(animated: true) | |
| } | |
| @objc private func handlePan(_ gesture: UIPanGestureRecognizer) { | |
| guard let gestureView = gesture.view, | |
| gesture.translation(in: gestureView).y >= 0 else { return } // Make sure we only recognize downward gestures. | |
| let percent = gesture.translation(in: gestureView).y / gestureView.bounds.height | |
| let velocityY = gesture.velocity(in: gestureView).y | |
| switch gesture.state { | |
| case .began: | |
| isInteractive = true | |
| presentedViewController.dismiss(animated: true, completion: nil) | |
| case .changed: | |
| interactor.update(percent) | |
| case .cancelled: | |
| interactor.cancel() | |
| isInteractive = false | |
| case .ended: | |
| // Finish the animation if the user flicked the modal quickly (i.e. high velocity), or dragged it | |
| // more than 50% percent down. | |
| if percent > 0.5 || velocityY > 1300 { | |
| // Multiply the animation duration by the velocity, to make sure the modal dismiss as fast as the user swiped. | |
| // If the user pulled down slowly though, we want to use the default duration, hence the max(). | |
| interactor.completionSpeed = max(1, (gestureView.frame.height * (1 / interactor.duration) / velocityY)) | |
| interactor.finish() | |
| } else { | |
| interactor.cancel() | |
| } | |
| isInteractive = false | |
| default: | |
| break | |
| } | |
| } | |
| } | |
| // MARK: UIViewControllerAnimatedTransitioning | |
| extension SheetModalPresentationController: UIViewControllerAnimatedTransitioning { | |
| func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { | |
| 0.5 | |
| } | |
| func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { | |
| interruptibleAnimator(using: transitionContext).startAnimation() | |
| } | |
| func interruptibleAnimator(using transitionContext: UIViewControllerContextTransitioning) -> UIViewImplicitlyAnimating { | |
| propertyAnimator = UIViewPropertyAnimator(duration: transitionDuration(using: transitionContext), | |
| timingParameters: UICubicTimingParameters()) | |
| propertyAnimator.addAnimations { | |
| // Move the view down. | |
| transitionContext.view(forKey: .from)?.frame.origin.y = transitionContext.containerView.frame.maxY | |
| } | |
| propertyAnimator.addCompletion { _ in | |
| transitionContext.completeTransition(!transitionContext.transitionWasCancelled) | |
| } | |
| return propertyAnimator | |
| } | |
| } | |
| // MARK: UIViewControllerTransitioningDelegate | |
| extension SheetModalPresentationController: UIViewControllerTransitioningDelegate { | |
| func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? { | |
| self | |
| } | |
| func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? { | |
| self | |
| } | |
| func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? { | |
| isInteractive ? interactor : nil | |
| } | |
| } | |
| // MARK: UIViewController Extension | |
| extension UIViewController { | |
| func presentAsSheet(_ vc: UIViewController, height: CGFloat) { | |
| let presentationController = SheetModalPresentationController(presentedViewController: vc, | |
| presenting: self, | |
| height: height) | |
| vc.transitioningDelegate = presentationController | |
| vc.modalPresentationStyle = .custom | |
| present(vc, animated: true) | |
| } | |
| } | |
| // Example usage: | |
| final class ViewController: UIViewController { | |
| override func viewDidLoad() { | |
| super.viewDidLoad() | |
| view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(presentPressed))) | |
| } | |
| @objc private func presentPressed() { | |
| presentAsSheet(ModalViewController(), height: 600) | |
| } | |
| } | |
| final class ModalViewController: UIViewController { | |
| override func viewDidLoad() { | |
| super.viewDidLoad() | |
| view.backgroundColor = .blue | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment