Skip to content

Instantly share code, notes, and snippets.

@vinczebalazs
Last active January 9, 2023 23:02
Show Gist options
  • Star 9 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save vinczebalazs/855c03f926d7c379c3e3e2eb5a63da20 to your computer and use it in GitHub Desktop.
Save vinczebalazs/855c03f926d7c379c3e3e2eb5a63da20 to your computer and use it in GitHub Desktop.
SheetModalPresentationController.swift
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