Skip to content

Instantly share code, notes, and snippets.

@vinczebalazs
Last active February 1, 2024 04:40
Show Gist options
  • Star 43 You must be signed in to star a gist
  • Fork 10 You must be signed in to fork a gist
  • Save vinczebalazs/ee1f2b466f969fa70d424d4480695325 to your computer and use it in GitHub Desktop.
Save vinczebalazs/ee1f2b466f969fa70d424d4480695325 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.
import UIKit
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
}
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
}
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
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
override var frameOfPresentedViewInContainerView: CGRect {
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() {
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 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 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() {
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 {
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:
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
}
}
/// 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
}
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
}
}
@ioslam
Copy link

ioslam commented Apr 16, 2020

@vinczebalazs: thank you, it works fine but the presented (View-Controller) which I'm dismissing has a video when I dismiss it, the video still running in the background, even if I pause the video it doesn't dismiss from the memory,
please any help?

@Pyxisq
Copy link

Pyxisq commented Jun 14, 2020

@ioslam That happens because your property animator has strong reference to UIViewControllerContextTransitioning, which has strong reference to UIPresentationController. So you have a reference cycle. To break cycle you can set weak reference to UIViewControllerContextTransitioning in your animator.

propertyAnimator.addAnimations { [weak transitionContext] in
            // Move the view down.
           guard let transitionContext = transitionContext else { return }
            transitionContext.view(forKey: .from)?.frame.origin.y = transitionContext.containerView.frame.maxY
        }
        propertyAnimator.addCompletion { [weak transitionContext] _ in
           guard let transitionContext = transitionContext else { return }
            transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
        }

@ioslam
Copy link

ioslam commented Jun 14, 2020

@Pyxisq , yeah now the problem is fixed, your explanation is quite right, thank you so much for your reply.

@mrfarukturgut
Copy link

@vinczebalazs: thanks for sharing this useful snippet. It is really close what I want and I managed to do something UIView.animate, however as you mentioned about it in your related blog post it does not work very well when it comes to animation and dragging. So I wanted to make upper radial corners to be shown and I managed to do it by playing with frame inside frameOfPresentedViewInContainerView. Just one more thing though, I want it to follow gesture upwards. Like downward gesture but opposite direction and with upper limit. I tried to disable gesture.translation(in: gestureView).y >= 0 from the code and it did not work. Afterwards, I looked for the UIPercentDrivenInteractiveTransition's docs and it says update will work when the percent is in between 0 and 1. I have no idea what else to do. This kind of animating, I am not familiar with.

@vinczebalazs
Copy link
Author

@vinczebalazs: thank you, it works fine but the presented (View-Controller) which I'm dismissing has a video when I dismiss it, the video still running in the background, even if I pause the video it doesn't dismiss from the memory,
please any help?

Updated the Gist to address this issue.

@vinczebalazs
Copy link
Author

@vinczebalazs: thanks for sharing this useful snippet. It is really close what I want and I managed to do something UIView.animate, however as you mentioned about it in your related blog post it does not work very well when it comes to animation and dragging. So I wanted to make upper radial corners to be shown and I managed to do it by playing with frame inside frameOfPresentedViewInContainerView. Just one more thing though, I want it to follow gesture upwards. Like downward gesture but opposite direction and with upper limit. I tried to disable gesture.translation(in: gestureView).y >= 0 from the code and it did not work. Afterwards, I looked for the UIPercentDrivenInteractiveTransition's docs and it says update will work when the percent is in between 0 and 1. I have no idea what else to do. This kind of animating, I am not familiar with.

If you send me some code I can take a look

@vinczebalazs
Copy link
Author

vinczebalazs commented Jun 17, 2020

There is also an updated version of this snippet with automatic height calculation and some other improvements!

@mrfarukturgut
Copy link

@vinczebalazs here is my code.

    @objc private func handleDismiss(_ sender: UIPanGestureRecognizer) {
        let dragOnY = sender.translation(in: self.view).y
        let stableYPosition =  -self.sheetHeight + 10

        switch sender.state {
        case .changed:
            if dragOnY + stableYPosition > stableYPosition - 50 {
                UIView.animate(withDuration: 0.3, delay: 0, usingSpringWithDamping: 0.7, initialSpringVelocity: 1, options: .curveEaseOut, animations:{
                    self.view.transform = dragOnY < 100 ? CGAffineTransform(translationX: 0, y:  stableYPosition + dragOnY) : .identity
                })
            }
            if self.view.transform.ty > 0 {
                dismiss(animated: true, completion: nil)
            }
        case .ended:
            if dragOnY < 100 {
                UIView.animate(withDuration: 0.3, delay: 0, usingSpringWithDamping: 0.7, initialSpringVelocity: 1, options: .curveEaseOut, animations:{
                    self.view.transform = CGAffineTransform(translationX: 0, y: stableYPosition)
                })
            } else {
                self.dismiss(animated: true, completion: nil)
            }
        default:break
        }
    }

@mrfarukturgut
Copy link

I can share the full snippet if you want.

@vinczebalazs
Copy link
Author

vinczebalazs commented Jun 17, 2020

If I understand correctly, you want to allow the user to drag upwards a little even if when the view is not being dismissed. First off, you should not animate the view along a gesture, instead do something like this:

presentedView?.frame.origin.y = startY + gesture.translation(in: containerView).y * 0.3

Make sure you set startY to the current y position of the view's frame when the gesture begins. We use a multiplier on the translation to move the view slower than the user's finer. This signals to the user that the gesture is "not allowed". Once the gesture ended, you can perform a spring animation (I recommend a damping of 1.0) to have it bounce back to the startY.

Hope this helps.

@mrfarukturgut
Copy link

yeah that is my intention. I will try your answer and let you know about the result. However I quite did not get where can I change damping while not using UIView.animate

@mrfarukturgut
Copy link

yeah that is my intention. I will try your answer and let you know about the result. However I quite did not get where can I change damping while not using UIView.animate

I have tried the line with various configurations and places. could not make it work, the presentedView just goes berzerk when I add the line somewhere.

@vinczebalazs
Copy link
Author

vinczebalazs commented Oct 11, 2020

Sorry for the late reply! I am very busy at the moment and don't really have time to debug the issue. However, it's been a while since I posted this gist and have been using an updated version of the code (which I did not experience any issues with). I updated the gist with the new code. It's a bit more complex, but has support for scroll views and moving the view up when the keyboard is toggled. Let me know if it works for you!

@malikkulsoom
Copy link

Everything works as expected but there's one problem that i'm facing, If anyone could help me with that.
I'm able to present view controller as bottom sheet but my view controller also has a texfield and i'm moving my viewcontroller up when keyboard shows and move it down when keyboard closes.

But my view controller doesn't come down when i'm hiding keyboard

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment