Skip to content

Instantly share code, notes, and snippets.

@RaajeevChandran
Last active January 13, 2026 15:36
Show Gist options
  • Select an option

  • Save RaajeevChandran/064820653e5e98bc370667d2a478bb38 to your computer and use it in GitHub Desktop.

Select an option

Save RaajeevChandran/064820653e5e98bc370667d2a478bb38 to your computer and use it in GitHub Desktop.
import UIKit
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
let btn = UIButton(type: .system)
btn.translatesAutoresizingMaskIntoConstraints = false
btn.setTitle("Show Sheet", for: .normal)
btn.addTarget(self, action: #selector(tap), for: .touchUpInside)
view.addSubview(btn)
NSLayoutConstraint.activate([
btn.centerXAnchor.constraint(equalTo: view.centerXAnchor),
btn.centerYAnchor.constraint(equalTo: view.centerYAnchor)
])
}
@objc func tap() {
let bottomSheetVC = OurBottomSheetContentViewController()
let delegate = BottomSheetTransitioningDelegate()
bottomSheetVC.transitioningDelegate = delegate
bottomSheetVC.modalPresentationStyle = .custom
present(bottomSheetVC, animated: true, completion: nil)
}
}
class OurBottomSheetContentViewController: UIViewController {
let contentView = ContentView()
override func loadView() {
super.loadView()
self.view = contentView
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
}
}
class ContentView: UIView {
let titleLabel = UILabel()
let subtitleLabel = UILabel()
let okButton = UIButton()
init() {
super.init(frame: .zero)
titleLabel.translatesAutoresizingMaskIntoConstraints = false
titleLabel.font = .systemFont(ofSize: 22, weight: .bold)
titleLabel.text = "What's up?"
subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
subtitleLabel.font = .systemFont(ofSize: 18, weight: .medium)
subtitleLabel.text = "This is a custom bottom sheet that we made from scratch!"
subtitleLabel.numberOfLines = 0
subtitleLabel.textAlignment = .center
okButton.translatesAutoresizingMaskIntoConstraints = false
okButton.backgroundColor = .systemBlue
okButton.setTitle("OK", for: .normal)
okButton.setTitleColor(.white, for: .normal)
okButton.layer.cornerRadius = 20
addSubview(titleLabel)
addSubview(subtitleLabel)
addSubview(okButton)
NSLayoutConstraint.activate([
titleLabel.topAnchor.constraint(equalTo: topAnchor, constant: 20),
titleLabel.centerXAnchor.constraint(equalTo: centerXAnchor),
subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 10),
subtitleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16),
subtitleLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16),
okButton.topAnchor.constraint(equalTo: subtitleLabel.bottomAnchor, constant: 10),
okButton.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16),
okButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16),
okButton.heightAnchor.constraint(equalToConstant: 50),
okButton.bottomAnchor.constraint(equalTo: safeAreaLayoutGuide.bottomAnchor)
])
}
required init?(coder: NSCoder) {
fatalError()
}
}
class BottomSheetTransitioningDelegate: NSObject {
let transition: BottomSheetTransition
override init() {
self.transition = BottomSheetTransition()
}
}
extension BottomSheetTransitioningDelegate: UIViewControllerTransitioningDelegate {
func animationController(
forPresented presented: UIViewController,
presenting: UIViewController,
source: UIViewController
) -> UIViewControllerAnimatedTransitioning? {
transition.presenting = true
transition.wantsInteractiveStart = false
return transition
}
func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
transition.presenting = false
return transition
}
func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
transition.presenting = false
return transition
}
func presentationController(
forPresented presented: UIViewController,
presenting: UIViewController?,
source: UIViewController
) -> UIPresentationController? {
BottomSheetPresentationController(presentedViewController: presented,
presenting: presenting)
}
}
class BottomSheetTransition: UIPercentDrivenInteractiveTransition {
private var dismissalAnimator: UIViewPropertyAnimator?
private var presentationAnimator: UIViewPropertyAnimator?
private let animationDuration = 0.5
private let dampingRatio: Double = 0.9
var dismissFractionComplete: CGFloat {
dismissalAnimator?.fractionComplete ?? .zero
}
var presenting = true
}
extension BottomSheetTransition: UIViewControllerAnimatedTransitioning {
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
animationDuration
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
let animator = interruptibleAnimator(using: transitionContext)
animator.startAnimation()
}
func interruptibleAnimator(using transitionContext: UIViewControllerContextTransitioning) -> UIViewImplicitlyAnimating {
presenting ? presentationAnimator(using: transitionContext) : dismissAnimator(using: transitionContext)
}
private func presentationAnimator(
using transitionContext: UIViewControllerContextTransitioning
) -> UIViewImplicitlyAnimating {
guard let presentationAnimator else {
guard
let toViewController = transitionContext.viewController(forKey: .to),
let toView = transitionContext.view(forKey: .to)
else {
return UIViewPropertyAnimator()
}
let animator = UIViewPropertyAnimator(
duration: transitionDuration(using: transitionContext),
dampingRatio: dampingRatio
)
presentationAnimator = animator
toView.frame = transitionContext.finalFrame(for: toViewController)
toView.frame.origin.y = transitionContext.containerView.frame.maxY
transitionContext.containerView.addSubview(toView)
animator.addAnimations {
toView.frame = transitionContext.finalFrame(for: toViewController)
}
animator.addCompletion { [weak self] position in
self?.presentationAnimator = nil
guard case .end = position else {
transitionContext.completeTransition(false)
return
}
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
}
return animator
}
return presentationAnimator
}
private func dismissAnimator(
using transitionContext: UIViewControllerContextTransitioning
) -> UIViewImplicitlyAnimating {
guard let dismissAnimator = self.dismissalAnimator else {
guard let fromView = transitionContext.view(forKey: .from) else {
return UIViewPropertyAnimator()
}
let animator = UIViewPropertyAnimator(
duration: transitionDuration(using: transitionContext),
dampingRatio: dampingRatio
)
dismissalAnimator = animator
animator.addAnimations {
fromView.frame.origin.y = fromView.frame.maxY
}
animator.addCompletion { [weak self] position in
self?.dismissalAnimator = nil
guard case .end = position else {
transitionContext.completeTransition(false)
return
}
fromView.removeFromSuperview()
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
}
return animator
}
return dismissAnimator
}
}
import UIKit
class BottomSheetPresentationController: UIPresentationController {
private let dismissThreshold: CGFloat = 0.3
private let grabberView: UIView = {
let view = UIView()
view.frame.size = CGSize(width: 50, height: 3)
view.backgroundColor = .lightGray
return view
}()
private let dimmedBackgroundView: UIView = {
let view = UIView()
view.isUserInteractionEnabled = true
return view
}()
private lazy var panGesture = UIPanGestureRecognizer(target: self, action: #selector(pannedPresentedView))
private lazy var tapGestureToDismiss = UITapGestureRecognizer(target: self, action: #selector(tappedDimmedBackgroundView))
private var transitioningDelegate: BottomSheetTransitioningDelegate? {
presentedViewController.transitioningDelegate as? BottomSheetTransitioningDelegate
}
override var frameOfPresentedViewInContainerView: CGRect {
guard let containerView, let presentedView else {
return super.frameOfPresentedViewInContainerView
}
let maximumWidth = containerView.bounds.width
let maximumHeight = containerView.frame.height - containerView.safeAreaInsets.top - containerView.safeAreaInsets.bottom
let fittingSize = CGSize(width: maximumWidth, height: UIView.layoutFittingCompressedSize.height)
let presentedViewSize = presentedView.systemLayoutSizeFitting(
fittingSize,
withHorizontalFittingPriority: .required,
verticalFittingPriority: .defaultLow
)
let presentedViewHeight = presentedViewSize.height
let targetHeight = presentedViewHeight == .zero ? maximumHeight : presentedViewHeight
let finalHeight = min(targetHeight, maximumHeight) + containerView.safeAreaInsets.bottom
let finalSize = CGSize(width: maximumWidth, height: finalHeight)
let finalOrigin = CGPoint(x: .zero, y: containerView.frame.maxY - finalSize.height)
return CGRect(origin: finalOrigin, size: finalSize)
}
override func presentationTransitionWillBegin() {
super.presentationTransitionWillBegin()
containerView?.addSubview(dimmedBackgroundView)
dimmedBackgroundView.backgroundColor = .black.withAlphaComponent(0.5)
presentedView?.addSubview(grabberView)
presentedViewController.transitionCoordinator?.animate(alongsideTransition: {
[weak self] _ in
guard let self else { return }
self.presentedView?.layer.cornerRadius = 25
})
}
override func containerViewDidLayoutSubviews() {
super.containerViewDidLayoutSubviews()
guard let presentedView, let containerView else {
return
}
dimmedBackgroundView.frame = containerView.bounds
grabberView.frame.origin.y = 8
grabberView.center.x = presentedView.center.x
grabberView.layer.cornerRadius = grabberView.frame.height / 2
setupInteraction()
presentedView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
presentedViewController.additionalSafeAreaInsets.top = grabberView.frame.maxY
}
override func dismissalTransitionWillBegin() {
super.dismissalTransitionWillBegin()
presentedViewController.transitionCoordinator?.animate(alongsideTransition: { [weak self] _ in
guard let self else { return }
self.presentedView?.layer.cornerRadius = .zero
self.dimmedBackgroundView.backgroundColor = .clear
})
}
}
// MARK: Pan Gesture
extension BottomSheetPresentationController {
private func setupInteraction() {
guard let presentedView = presentedView else {
return
}
func addGestureIfNeeded(_ gesture: UIGestureRecognizer, to view: UIView, delegate: UIGestureRecognizerDelegate? = nil) {
if let recognizers = view.gestureRecognizers, recognizers.contains(gesture) {
return
}
gesture.delegate = delegate
view.addGestureRecognizer(gesture)
}
addGestureIfNeeded(panGesture, to: presentedView, delegate: self)
addGestureIfNeeded(tapGestureToDismiss, to: dimmedBackgroundView)
}
private func dismiss(withInteraction: Bool) {
transitioningDelegate?.transition.wantsInteractiveStart = withInteraction
presentedViewController.dismiss(animated: true)
}
@objc private func pannedPresentedView(_ recognizer: UIPanGestureRecognizer) {
guard let presentedView = presentedView else {
return
}
switch recognizer.state {
case .began:
transitioningDelegate?.transition.wantsInteractiveStart = true
case .changed:
let translation = recognizer.translation(in: presentedView)
didGestureTranslationChange(translation)
case .ended:
let translation = recognizer.translation(in: presentedView)
let progress = translation.y / presentedView.frame.height
if progress > dismissThreshold {
dismiss(withInteraction: true)
transitioningDelegate?.transition.finish()
} else {
UIView.animate(withDuration: 0.2) {
presentedView.transform = .identity
}
transitioningDelegate?.transition.cancel()
}
case .cancelled, .failed:
break
default:
break
}
}
private func didGestureTranslationChange(_ translation: CGPoint) {
guard let presentedView = presentedView else {
return
}
let translationY = max(0, translation.y)
presentedView.transform = CGAffineTransform(translationX: 0, y: translationY)
let progress = translationY / presentedView.frame.height
transitioningDelegate?.transition.update(progress)
}
private func didInteractionEnd() {
guard let presentedView = presentedView else {
return
}
if let transitioningDelegate = transitioningDelegate {
let dismissFraction = transitioningDelegate.transition.dismissFractionComplete
if dismissFraction > dismissThreshold {
transitioningDelegate.transition.finish()
presentedViewController.dismiss(animated: true)
} else {
transitioningDelegate.transition.cancel()
UIView.animate(withDuration: 0.3) {
presentedView.transform = .identity
}
}
} else {
UIView.animate(withDuration: 0.3) {
presentedView.transform = .identity
}
}
}
@objc private func tappedDimmedBackgroundView() {
dismiss(withInteraction: false)
}
}
extension BottomSheetPresentationController: UIGestureRecognizerDelegate {
public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return true
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment