Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save V8tr/bb4157dbab3f14b2f96cb8b51a44a6e2 to your computer and use it in GitHub Desktop.
Save V8tr/bb4157dbab3f14b2f96cb8b51a44a6e2 to your computer and use it in GitHub Desktop.

Revisions

  1. @vinczebalazs vinczebalazs revised this gist Oct 11, 2020. 1 changed file with 242 additions and 105 deletions.
    347 changes: 242 additions & 105 deletions SheetModalPresentationController.swift
    Original file line number Diff line number Diff line change
    @@ -1,101 +1,229 @@
    //
    // SheetModalPresentationController.swift
    //
    //
    // Created by Balazs Vincze on 2020. 01. 14..
    // Copyright © 2020. Balazs Vincze. All rights reserved.
    //

    import UIKit

    final class SheetModalPresentationController: UIPresentationController {
    extension UIView {

    var allSubviews: [UIView] {
    subviews + subviews.flatMap { $0.allSubviews }
    }

    func firstSubview<T: UIView>(of type: T.Type) -> T? {
    allSubviews.first { $0 is T } as? T
    }

    func findFirstResponder() -> UIView? {
    for subview in subviews {
    if subview.isFirstResponder {
    return subview
    }

    // MARK: Private Properties
    if let recursiveSubView = subview.findFirstResponder() {
    return recursiveSubView
    }
    }

    return nil
    }

    }

    extension UIApplication {

    static var statusBarHeight: CGFloat {
    Self.shared.statusBarFrame.height
    }

    }

    extension UIViewController {

    /// Return the preferred height of the view controller, taking scrollviews into account.
    var preferredHeight: CGFloat {
    /// If the view controller provides it's own preferred size, use it.
    if preferredContentSize.height > 0 {
    return preferredContentSize.height
    }

    return calculatePreferredHeight()
    }

    func calculatePreferredHeight() -> 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 only the view itself.
    var height = max(0, view.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize).height - insets)

    // Support for UITableViewControllers.
    if let tableView = view as? UITableView {
    height += tableView.contentSize.height + tableView.contentInset.top + tableView.contentInset.bottom
    return height
    }

    // Include scroll views in the height calculation.
    height += view.subviews.filter { $0 is UIScrollView }.reduce(CGFloat(0), { result, view in
    if view.intrinsicContentSize.height <= 0 {
    // If a scroll view does not have an intrinsic content size set, use the content size.
    let scrollView = view as! UIScrollView
    return result + scrollView.contentSize.height + scrollView.contentInset.top + scrollView.contentInset.bottom
    } else {
    return result
    }
    })

    return height
    }

    func requestHeightUpdate() {
    // Set the preferredContentSize to force a preferredContentSizeDidChange call in the parent.
    preferredContentSize.height += 1
    preferredContentSize.height = 0
    }

    private let height: CGFloat

    func presentAsSheet(_ vc: UIViewController, isDismissable: Bool) {
    let presentationController = SheetModalPresentationController(presentedViewController: vc,
    presenting: self,
    isDismissable: isDismissable)
    vc.transitioningDelegate = presentationController
    vc.modalPresentationStyle = .custom
    present(vc, animated: true)
    }

    }

    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

    // MARK: Initializers

    init(presentedViewController: UIViewController, presenting presentingViewController: UIViewController?, height: CGFloat) {
    self.height = min(height, presentedViewController.view.frame.height)

    super.init(presentedViewController: presentedViewController, presenting: presentingViewController)
    private var navigationController: UINavigationController {
    presentedViewController as! UINavigationController
    }
    private var nestedViewController: UIViewController? {
    navigationController.viewControllers.last
    }
    private var scrollView: UIScrollView? {
    nestedViewController?.view as? UIScrollView ?? nestedViewController?.view.firstSubview(of: UIScrollView.self)
    }
    private let topOffset = UIApplication.statusBarHeight + 20

    // MARK: Public Properties

    // 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
    guard let containerBounds = containerView?.bounds,
    let nestedViewController = nestedViewController,
    let window = UIApplication.shared.keyWindow else { return .zero }

    let extraPadding = navigationController.navigationBar.frame.height +
    navigationController.additionalSafeAreaInsets.top +
    window.safeAreaInsets.bottom +
    nestedViewController.additionalSafeAreaInsets.top +
    nestedViewController.additionalSafeAreaInsets.bottom
    var frame = containerBounds
    frame.size.height = min(nestedViewController.preferredHeight + extraPadding,
    containerBounds.height - topOffset)
    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)

    registerForKeyboardNotifications()
    }

    // MARK: Public Methods

    override func presentationTransitionWillBegin() {
    super.presentationTransitionWillBegin()

    guard let containerBounds = containerView?.bounds else { return }
    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.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
    presentedView.layer.cornerRadius = 40

    // 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)))
    // Add a dimming view below the presented view controller.
    dimmingView.backgroundColor = .black
    dimmingView.frame = containerBounds
    dimmingView.alpha = 0
    containerView?.insertSubview(dimmingView, at: 0)

    presentedViewController.transitionCoordinator?.animate(alongsideTransition: { [unowned self] in
    // 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 sheet and keyboard dismissal.
    dimmingView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handleDismiss)))

    presentedViewController.transitionCoordinator?.animate(alongsideTransition: { [unowned self] _ in
    self.dimmingView.alpha = 0.5
    })
    }

    override func dismissalTransitionWillBegin() {
    super.dismissalTransitionWillBegin()

    presentedViewController.transitionCoordinator?.animate(alongsideTransition: { [unowned self] in
    presentedViewController.transitionCoordinator?.animate(alongsideTransition: { [unowned self] _ in
    self.dimmingView.alpha = 0
    })
    }

    // MARK: Private Functions

    @objc private func dismiss() {
    presentedViewController.dismiss(animated: true)
    override func dismissalTransitionDidEnd(_ completed: Bool) {
    // Not setting this to nil causes a retain cycle for some reason.
    propertyAnimator = nil
    }

    @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.
    override func preferredContentSizeDidChange(forChildContentContainer container: UIContentContainer) {
    super.preferredContentSizeDidChange(forChildContentContainer: container)

    let percent = gesture.translation(in: gestureView).y / gestureView.bounds.height
    if propertyAnimator != nil && !propertyAnimator.isRunning {
    presentedView?.frame = frameOfPresentedViewInContainerView
    presentedView?.layoutIfNeeded()
    }
    }

    // MARK: Private Methods

    @objc
    private func handleDismiss() {
    presentedView?.endEditing(true)
    if isDismissable {
    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:
    isInteractive = true
    presentedViewController.dismiss(animated: true, completion: nil)
    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: gestureView).y
    // Finish the animation if the user flicked the modal quickly (i.e. high velocity), or dragged it more than 50% down.
    if percent > 0.5 || velocity > 1600 {
    // Multiply the animation duration by the velocity, to make sure the modal dismisses 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, velocity / (gestureView.frame.height * (1 / interactor.duration)))
    let velocity = gesture.velocity(in: containerView).y
    interactor.completionSpeed = 0.9
    if percent > 0.3 || velocity > 1600 {
    interactor.finish()
    } else {
    interactor.cancel()
    @@ -106,12 +234,53 @@ final class SheetModalPresentationController: UIPresentationController {
    }
    }

    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
    }
    }

    /// Handle the keyboard.

    private func registerForKeyboardNotifications() {
    NotificationCenter.default.addObserver(self, selector: #selector(keyboardToggled(notification:)),
    name: UIResponder.keyboardWillShowNotification, object: nil)
    NotificationCenter.default.addObserver(self, selector: #selector(keyboardToggled(notification:)),
    name: UIResponder.keyboardWillHideNotification, object: nil)
    }

    @objc
    private func keyboardToggled(notification: NSNotification) {
    guard let containerHeight = containerView?.bounds.height,
    let presentedView = presentedView,
    let textInput = presentedView.findFirstResponder(),
    let textInputFrame = textInput.superview?.convert(textInput.frame, to: presentedView.superview)
    else {
    return assertionFailure()
    }

    // Adjust the presented view to move the active text input out of the keyboards's way (if needed).
    if notification.name == UIResponder.keyboardWillShowNotification {
    guard let keyboardFrame = (notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue
    else { return assertionFailure() }

    let keyboardOverlap = textInputFrame.maxY - keyboardFrame.minY + 20
    if keyboardOverlap > 0 {
    presentedView.frame.origin.y = max(presentedView.frame.minY - keyboardOverlap, topOffset)
    }
    } else if notification.name == UIResponder.keyboardWillHideNotification {
    presentedView.frame.origin.y = containerHeight - presentedView.frame.size.height
    }
    }

    }

    // MARK: UIViewControllerAnimatedTransitioning

    extension SheetModalPresentationController: UIViewControllerAnimatedTransitioning {

    func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
    0.5
    }
    @@ -122,24 +291,34 @@ extension SheetModalPresentationController: UIViewControllerAnimatedTransitionin

    func interruptibleAnimator(using transitionContext: UIViewControllerContextTransitioning) -> UIViewImplicitlyAnimating {
    propertyAnimator = UIViewPropertyAnimator(duration: transitionDuration(using: transitionContext),
    timingParameters: UICubicTimingParameters())
    propertyAnimator.addAnimations { _ in
    // Move the view down.
    transitionContext.view(forKey: .from)?.frame.origin.y = transitionContext.containerView.frame.maxY
    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? {
    func presentationController(forPresented presented: UIViewController, presenting: UIViewController?,
    source: UIViewController) -> UIPresentationController? {
    self
    }

    func animationController(forPresented presented: UIViewController, presenting: UIViewController,
    source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
    self
    }

    @@ -151,46 +330,4 @@ extension SheetModalPresentationController: UIViewControllerTransitioningDelegat
    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
    }

    }
    }
  2. @vinczebalazs vinczebalazs revised this gist Jun 17, 2020. 1 changed file with 3 additions and 3 deletions.
    6 changes: 3 additions & 3 deletions SheetModalPresentationController.swift
    Original file line number Diff line number Diff line change
    @@ -55,15 +55,15 @@ final class SheetModalPresentationController: UIPresentationController {
    dimmingView.alpha = 0
    containerView?.insertSubview(dimmingView, at: 0)

    presentedViewController.transitionCoordinator?.animate(alongsideTransition: { _ in
    presentedViewController.transitionCoordinator?.animate(alongsideTransition: { [unowned self] in
    self.dimmingView.alpha = 0.5
    })
    }

    override func dismissalTransitionWillBegin() {
    super.dismissalTransitionWillBegin()

    presentedViewController.transitionCoordinator?.animate(alongsideTransition: { _ in
    presentedViewController.transitionCoordinator?.animate(alongsideTransition: { [unowned self] in
    self.dimmingView.alpha = 0
    })
    }
    @@ -123,7 +123,7 @@ extension SheetModalPresentationController: UIViewControllerAnimatedTransitionin
    func interruptibleAnimator(using transitionContext: UIViewControllerContextTransitioning) -> UIViewImplicitlyAnimating {
    propertyAnimator = UIViewPropertyAnimator(duration: transitionDuration(using: transitionContext),
    timingParameters: UICubicTimingParameters())
    propertyAnimator.addAnimations {
    propertyAnimator.addAnimations { _ in
    // Move the view down.
    transitionContext.view(forKey: .from)?.frame.origin.y = transitionContext.containerView.frame.maxY
    }
  3. @vinczebalazs vinczebalazs revised this gist Jan 19, 2020. 1 changed file with 1 addition and 1 deletion.
    2 changes: 1 addition & 1 deletion SheetModalPresentationController.swift
    Original file line number Diff line number Diff line change
    @@ -93,7 +93,7 @@ final class SheetModalPresentationController: UIPresentationController {
    let velocity = gesture.velocity(in: gestureView).y
    // Finish the animation if the user flicked the modal quickly (i.e. high velocity), or dragged it more than 50% down.
    if percent > 0.5 || velocity > 1600 {
    // Multiply the animation duration by the velocity, to make sure the modal dismiss as fast as the user swiped.
    // Multiply the animation duration by the velocity, to make sure the modal dismisses 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, velocity / (gestureView.frame.height * (1 / interactor.duration)))
    interactor.finish()
  4. @vinczebalazs vinczebalazs revised this gist Jan 19, 2020. 1 changed file with 4 additions and 5 deletions.
    9 changes: 4 additions & 5 deletions SheetModalPresentationController.swift
    Original file line number Diff line number Diff line change
    @@ -27,7 +27,6 @@ final class SheetModalPresentationController: UIPresentationController {
    }

    // MARK: Function Overrides

    override var frameOfPresentedViewInContainerView: CGRect {
    guard let containerBounds = containerView?.bounds else { return .zero }

    @@ -80,7 +79,6 @@ final class SheetModalPresentationController: UIPresentationController {
    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:
    @@ -92,11 +90,12 @@ final class SheetModalPresentationController: UIPresentationController {
    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% down.
    if percent > 0.5 || velocityY > 1300 {
    let velocity = gesture.velocity(in: gestureView).y
    // Finish the animation if the user flicked the modal quickly (i.e. high velocity), or dragged it more than 50% down.
    if percent > 0.5 || velocity > 1600 {
    // 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, velocityY / (gestureView.frame.height * (1 / interactor.duration)))
    interactor.completionSpeed = max(1, velocity / (gestureView.frame.height * (1 / interactor.duration)))
    interactor.finish()
    } else {
    interactor.cancel()
  5. @vinczebalazs vinczebalazs revised this gist Jan 19, 2020. 1 changed file with 1 addition and 1 deletion.
    2 changes: 1 addition & 1 deletion SheetModalPresentationController.swift
    Original file line number Diff line number Diff line change
    @@ -96,7 +96,7 @@ final class SheetModalPresentationController: UIPresentationController {
    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, velocity / (gestureView.frame.height * (1 / interactor.duration)))
    interactor.completionSpeed = max(1, velocityY / (gestureView.frame.height * (1 / interactor.duration)))
    interactor.finish()
    } else {
    interactor.cancel()
  6. @vinczebalazs vinczebalazs revised this gist Jan 19, 2020. 1 changed file with 1 addition and 1 deletion.
    2 changes: 1 addition & 1 deletion SheetModalPresentationController.swift
    Original file line number Diff line number Diff line change
    @@ -96,7 +96,7 @@ final class SheetModalPresentationController: UIPresentationController {
    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.completionSpeed = max(1, velocity / (gestureView.frame.height * (1 / interactor.duration)))
    interactor.finish()
    } else {
    interactor.cancel()
  7. @vinczebalazs vinczebalazs revised this gist Jan 19, 2020. 1 changed file with 1 addition and 2 deletions.
    3 changes: 1 addition & 2 deletions SheetModalPresentationController.swift
    Original file line number Diff line number Diff line change
    @@ -92,8 +92,7 @@ final class SheetModalPresentationController: UIPresentationController {
    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.
    // Finish the animation if the user flicked the modal quickly (i.e. high velocity), or dragged it more than 50% 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().
  8. @vinczebalazs vinczebalazs revised this gist Jan 19, 2020. 1 changed file with 27 additions and 0 deletions.
    27 changes: 27 additions & 0 deletions SheetModalPresentationController.swift
    Original file line number Diff line number Diff line change
    @@ -169,3 +169,30 @@ extension UIViewController {
    }

    }

    // 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
    }

    }
  9. @vinczebalazs vinczebalazs revised this gist Jan 19, 2020. 1 changed file with 29 additions and 6 deletions.
    35 changes: 29 additions & 6 deletions SheetModalPresentationController.swift
    Original file line number Diff line number Diff line change
    @@ -14,11 +14,7 @@ final class SheetModalPresentationController: UIPresentationController {

    private let height: CGFloat
    private let interactor = UIPercentDrivenInteractiveTransition()
    private lazy var dimmingView: UIView = {
    let dimmingView = UIView()
    dimmingView.backgroundColor = .black
    return dimmingView
    }()
    private let dimmingView = UIView()
    private var propertyAnimator: UIViewPropertyAnimator!
    private var isInteractive = false

    @@ -35,9 +31,11 @@ final class SheetModalPresentationController: UIPresentationController {
    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
    }

    @@ -46,10 +44,14 @@ final class SheetModalPresentationController: UIPresentationController {

    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)
    @@ -74,7 +76,8 @@ final class SheetModalPresentationController: UIPresentationController {
    }

    @objc private func handlePan(_ gesture: UIPanGestureRecognizer) {
    guard let gestureView = gesture.view, gesture.translation(in: gestureView).y >= 0 else { return }
    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
    @@ -89,7 +92,11 @@ final class SheetModalPresentationController: UIPresentationController {
    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 {
    @@ -119,6 +126,7 @@ extension SheetModalPresentationController: UIViewControllerAnimatedTransitionin
    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
    @@ -146,3 +154,18 @@ extension SheetModalPresentationController: UIViewControllerTransitioningDelegat
    }

    }

    // 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)
    }

    }
  10. @vinczebalazs vinczebalazs revised this gist Jan 19, 2020. 1 changed file with 23 additions and 55 deletions.
    78 changes: 23 additions & 55 deletions SheetModalPresentationController.swift
    Original file line number Diff line number Diff line change
    @@ -12,15 +12,15 @@ final class SheetModalPresentationController: UIPresentationController {

    // MARK: Private Properties

    private let height: CGFloat
    private let interactor = UIPercentDrivenInteractiveTransition()
    private lazy var dimmingView: UIView = {
    let dimmingView = UIView()
    dimmingView.backgroundColor = .black
    return dimmingView
    }()
    private let interactor = UIPercentDrivenInteractiveTransition()
    private var propertyAnimator: UIViewPropertyAnimator!
    private var isInteractive = false
    private let height: CGFloat
    private var gestureStart: Date!

    // MARK: Initializers

    @@ -81,7 +81,6 @@ final class SheetModalPresentationController: UIPresentationController {

    switch gesture.state {
    case .began:
    gestureStart = Date()
    isInteractive = true
    presentedViewController.dismiss(animated: true, completion: nil)
    case .changed:
    @@ -91,8 +90,6 @@ final class SheetModalPresentationController: UIPresentationController {
    isInteractive = false
    case .ended:
    if percent > 0.5 || velocityY > 1300 {
    interactor.completionSpeed = max(1, (gestureView.frame.height * (1 / interactor.duration) / velocity.y))

    interactor.completionSpeed = max(1, (gestureView.frame.height * (1 / interactor.duration) / velocityY))
    interactor.finish()
    } else {
    @@ -106,70 +103,23 @@ final class SheetModalPresentationController: UIPresentationController {

    }

    // MARK: UIViewControllerTransitioningDelegate

    extension SheetPresentationController: UIViewControllerTransitioningDelegate {

    func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? {
    self
    }

    func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
    SheetTransitionAnimator(.dismissed)
    }

    func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
    isInteractive ? interactor : nil
    }

    }


    final class SheetTransitionAnimator: NSObject {

    enum TransitionType {
    case presented
    case dismissed
    }

    private let type: TransitionType
    private var propertyAnimator: UIViewPropertyAnimator!

    init(_ type: TransitionType) {
    self.type = type

    super.init()
    }

    }

    // MARK: UIViewControllerAnimatedTransitioning

    extension SheetTransitionAnimator: UIViewControllerAnimatedTransitioning {
    extension SheetModalPresentationController: UIViewControllerAnimatedTransitioning {

    func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
    0.5
    }

    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
    if type == .presented, let toView = transitionContext.view(forKey: .to) {
    transitionContext.containerView.addSubview(toView)
    toView.frame.origin.y = transitionContext.containerView.frame.maxY
    }
    interruptibleAnimator(using: transitionContext).startAnimation()
    }

    func interruptibleAnimator(using transitionContext: UIViewControllerContextTransitioning) -> UIViewImplicitlyAnimating {
    propertyAnimator = UIViewPropertyAnimator(duration: transitionDuration(using: transitionContext),
    timingParameters: UICubicTimingParameters())
    propertyAnimator.addAnimations {
    switch self.type {
    case .presented:
    guard let toVC = transitionContext.viewController(forKey: .to) else { break }
    toVC.view.frame = transitionContext.finalFrame(for: toVC)
    case .dismissed:
    transitionContext.view(forKey: .from)?.frame.origin.y = transitionContext.containerView.frame.maxY
    }
    transitionContext.view(forKey: .from)?.frame.origin.y = transitionContext.containerView.frame.maxY
    }
    propertyAnimator.addCompletion { _ in
    transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
    @@ -178,3 +128,21 @@ extension SheetTransitionAnimator: UIViewControllerAnimatedTransitioning {
    }

    }

    // 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
    }

    }
  11. @vinczebalazs vinczebalazs created this gist Jan 19, 2020.
    180 changes: 180 additions & 0 deletions SheetModalPresentationController.swift
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,180 @@
    //
    // 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 lazy var dimmingView: UIView = {
    let dimmingView = UIView()
    dimmingView.backgroundColor = .black
    return dimmingView
    }()
    private let interactor = UIPercentDrivenInteractiveTransition()
    private var isInteractive = false
    private let height: CGFloat
    private var gestureStart: Date!

    // 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 }

    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 }

    presentedView?.addGestureRecognizer(UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:))))
    presentedView?.layer.cornerRadius = 40

    dimmingView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(dismiss)))
    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 }

    let percent = gesture.translation(in: gestureView).y / gestureView.bounds.height
    let velocityY = gesture.velocity(in: gestureView).y

    switch gesture.state {
    case .began:
    gestureStart = Date()
    isInteractive = true
    presentedViewController.dismiss(animated: true, completion: nil)
    case .changed:
    interactor.update(percent)
    case .cancelled:
    interactor.cancel()
    isInteractive = false
    case .ended:
    if percent > 0.5 || velocityY > 1300 {
    interactor.completionSpeed = max(1, (gestureView.frame.height * (1 / interactor.duration) / velocity.y))

    interactor.completionSpeed = max(1, (gestureView.frame.height * (1 / interactor.duration) / velocityY))
    interactor.finish()
    } else {
    interactor.cancel()
    }
    isInteractive = false
    default:
    break
    }
    }

    }

    // MARK: UIViewControllerTransitioningDelegate

    extension SheetPresentationController: UIViewControllerTransitioningDelegate {

    func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? {
    self
    }

    func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
    SheetTransitionAnimator(.dismissed)
    }

    func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
    isInteractive ? interactor : nil
    }

    }


    final class SheetTransitionAnimator: NSObject {

    enum TransitionType {
    case presented
    case dismissed
    }

    private let type: TransitionType
    private var propertyAnimator: UIViewPropertyAnimator!

    init(_ type: TransitionType) {
    self.type = type

    super.init()
    }

    }

    // MARK: UIViewControllerAnimatedTransitioning

    extension SheetTransitionAnimator: UIViewControllerAnimatedTransitioning {

    func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
    0.5
    }

    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
    if type == .presented, let toView = transitionContext.view(forKey: .to) {
    transitionContext.containerView.addSubview(toView)
    toView.frame.origin.y = transitionContext.containerView.frame.maxY
    }
    interruptibleAnimator(using: transitionContext).startAnimation()
    }

    func interruptibleAnimator(using transitionContext: UIViewControllerContextTransitioning) -> UIViewImplicitlyAnimating {
    propertyAnimator = UIViewPropertyAnimator(duration: transitionDuration(using: transitionContext),
    timingParameters: UICubicTimingParameters())
    propertyAnimator.addAnimations {
    switch self.type {
    case .presented:
    guard let toVC = transitionContext.viewController(forKey: .to) else { break }
    toVC.view.frame = transitionContext.finalFrame(for: toVC)
    case .dismissed:
    transitionContext.view(forKey: .from)?.frame.origin.y = transitionContext.containerView.frame.maxY
    }
    }
    propertyAnimator.addCompletion { _ in
    transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
    }
    return propertyAnimator
    }

    }