Last active
January 9, 2023 23:02
-
-
Save vinczebalazs/855c03f926d7c379c3e3e2eb5a63da20 to your computer and use it in GitHub Desktop.
SheetModalPresentationController.swift
This file contains 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
import UIKit | |
final class SheetModalPresentationController: UIPresentationController { | |
// MARK: Private Properties | |
private let isDismissable: Bool | |
private let interactor = UIPercentDrivenInteractiveTransition() | |
private let dimmingView = UIView() | |
private var propertyAnimator: UIViewPropertyAnimator! | |
private var isInteractive = false | |
private var scrollView: UIScrollView? { | |
presentedView as? UIScrollView ?? presentedView?.firstSubview(of: UIScrollView.self) | |
} | |
// MARK: Public Properties | |
override var frameOfPresentedViewInContainerView: CGRect { | |
guard let containerBounds = containerView?.bounds else { return .zero } | |
var frame = containerBounds | |
frame.size.height = min(presentedViewController.preferredHeight, containerBounds.height - UIApplication.statusBarHeight - 20) | |
frame.origin.y = containerBounds.height - frame.size.height | |
return frame | |
} | |
// MARK: Initializers | |
init(presentedViewController: UIViewController, presenting presentingViewController: UIViewController?, | |
isDismissable: Bool) { | |
self.isDismissable = isDismissable | |
super.init(presentedViewController: presentedViewController, presenting: presentingViewController) | |
} | |
// MARK: Public Functions | |
override func presentationTransitionWillBegin() { | |
guard let containerBounds = containerView?.bounds, let presentedView = presentedView else { return } | |
// Configure the presented view. | |
containerView?.addSubview(presentedView) | |
presentedView.layoutIfNeeded() | |
presentedView.frame = frameOfPresentedViewInContainerView | |
presentedView.frame.origin.y = containerBounds.height | |
presentedView.layer.masksToBounds = true | |
presentedView.layer.cornerRadius = 40 | |
// Add a dimming view below the presented view controller. | |
dimmingView.backgroundColor = .black | |
dimmingView.frame = containerBounds | |
dimmingView.alpha = 0 | |
containerView?.insertSubview(dimmingView, at: 0) | |
// Add pan gesture recognizers for interactive dismissal. | |
presentedView.addGestureRecognizer(UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))) | |
scrollView?.panGestureRecognizer.addTarget(self, action: #selector(handlePan(_:))) | |
// Add tap recognizer for dismissal. | |
if isDismissable { | |
dimmingView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(dismiss))) | |
} | |
presentedViewController.transitionCoordinator?.animate(alongsideTransition: { [unowned self] _ in | |
self.dimmingView.alpha = 0.5 | |
}) | |
} | |
override func dismissalTransitionWillBegin() { | |
presentedViewController.transitionCoordinator?.animate(alongsideTransition: { [unowned self] _ in | |
self.dimmingView.alpha = 0 | |
}) | |
} | |
override func dismissalTransitionDidEnd(_ completed: Bool) { | |
// Not setting this to nil causes a retain cycle for some reason. | |
propertyAnimator = nil | |
} | |
override func preferredContentSizeDidChange(forChildContentContainer container: UIContentContainer) { | |
super.preferredContentSizeDidChange(forChildContentContainer: container) | |
if propertyAnimator != nil && !propertyAnimator.isRunning { | |
// Respond to height changes in the child view controller. | |
let animator = UIViewPropertyAnimator(duration: 0.5, timingParameters: UISpringTimingParameters(dampingRatio: 1.0)) | |
animator.addAnimations { | |
self.presentedView?.frame = self.frameOfPresentedViewInContainerView | |
} | |
animator.startAnimation() | |
} | |
} | |
// MARK: Private Functions | |
@objc private func dismiss() { | |
presentedViewController.dismiss(animated: true) | |
} | |
@objc private func handlePan(_ gesture: UIPanGestureRecognizer) { | |
guard isDismissable, let containerView = containerView else { return } | |
limitScrollView(gesture) | |
let percent = gesture.translation(in: containerView).y / containerView.bounds.height | |
switch gesture.state { | |
case .began: | |
if !presentedViewController.isBeingDismissed && scrollView?.contentOffset.y ?? 0 <= 0 { | |
isInteractive = true | |
presentedViewController.dismiss(animated: true) | |
} | |
case .changed: | |
interactor.update(percent) | |
case .cancelled: | |
interactor.cancel() | |
isInteractive = false | |
case .ended: | |
let velocity = gesture.velocity(in: containerView).y | |
interactor.completionSpeed = 0.9 | |
if percent > 0.3 || velocity > 1600 { | |
interactor.finish() | |
} else { | |
interactor.cancel() | |
} | |
isInteractive = false | |
default: | |
break | |
} | |
} | |
private func limitScrollView(_ gesture: UIPanGestureRecognizer) { | |
guard let scrollView = scrollView else { return } | |
if interactor.percentComplete > 0 { | |
// Don't let the scroll view scroll while dismissing. | |
scrollView.contentOffset.y = -scrollView.adjustedContentInset.top | |
} | |
} | |
} | |
// 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: UISpringTimingParameters(dampingRatio: 1.0, | |
initialVelocity: CGVector(dx: 1, dy: 1))) | |
propertyAnimator.addAnimations { [unowned self] in | |
if self.presentedViewController.isBeingPresented { | |
transitionContext.view(forKey: .to)?.frame = self.frameOfPresentedViewInContainerView | |
} else { | |
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(forPresented presented: UIViewController, presenting: UIViewController, | |
source: UIViewController) -> UIViewControllerAnimatedTransitioning? { | |
self | |
} | |
func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? { | |
self | |
} | |
func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? { | |
isInteractive ? interactor : nil | |
} | |
} | |
extension UIViewController { | |
/// Return the preferred height of the view controller, taking scrollviews into account. | |
var preferredHeight: CGFloat { | |
// Insets are all zero intially, but once setup they will influence the results of the systemLayoutSizeFitting method. | |
let insets = view.safeAreaInsets.top + view.safeAreaInsets.bottom | |
// We substract the insets from the height to always get the actual height of the only view itself. | |
var height = max(0, view.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize).height - insets) | |
height += view.subviews.filter { $0 is UIScrollView }.reduce(CGFloat(0), { x, view in | |
if view.intrinsicContentSize.height <= 0 { | |
// If a scroll view does not have an intrinsic content size set, use the content size. | |
return x + (view as! UIScrollView).contentSize.height | |
} else { | |
return x | |
} | |
}) | |
return height | |
} | |
} | |
extension UIView { | |
func firstSubview<T: UIView>(of type: T.Type) -> T? { | |
allSubviews.first { $0 is T } as? T | |
} | |
} | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment