Skip to content

Instantly share code, notes, and snippets.

@ertembiyik
Created February 17, 2024 16:50
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save ertembiyik/1a0d7002c80319e82c2e9b3ddc6773d7 to your computer and use it in GitHub Desktop.
Save ertembiyik/1a0d7002c80319e82c2e9b3ddc6773d7 to your computer and use it in GitHub Desktop.
import UIKit
final class TransitionDelegate: NSObject,
UIViewControllerTransitioningDelegate {
private let interactiveController = UIPercentDrivenInteractiveTransition()
private let duration = CATransaction.animationDuration()
func presentationController(forPresented presented: UIViewController,
presenting: UIViewController?,
source: UIViewController) -> UIPresentationController? {
return PresentationController(presentedViewController: presented,
presenting: presenting ?? source,
interactiveController: self.interactiveController)
}
func animationController(forPresented presented: UIViewController,
presenting: UIViewController,
source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
return PresentTransitioning(duration: duration)
}
func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
return DismissTransitioning(duration: duration)
}
func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
return self.interactiveController
}
}
final class PresentTransitioning: NSObject,
UIViewControllerAnimatedTransitioning {
private let duration: TimeInterval
init(duration: TimeInterval) {
self.duration = duration
}
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return self.duration
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
guard let animator = self.animator(using: transitionContext) else {
return
}
animator.startAnimation()
}
private func animator(using transitionContext: UIViewControllerContextTransitioning) -> UIViewImplicitlyAnimating? {
let containerSubviews = transitionContext.containerView.subviews
guard let to = transitionContext.viewController(forKey: .to),
let fromView = containerSubviews[safeIndex: containerSubviews.count - 2] else {
transitionContext.completeTransition(false)
return nil
}
let finalFrame = transitionContext.finalFrame(for: to)
to.view.frame = finalFrame.offsetBy(dx: 0, dy: finalFrame.height)
let spring = UISpringTimingParameters()
let animator = UIViewPropertyAnimator(duration: self.duration,
timingParameters: spring)
animator.addAnimations {
to.view.frame = finalFrame
}
animator.addAnimations {
fromView.transform = CGAffineTransform(scaleX: 0.9, y: 0.85)
}
animator.isInterruptible = true
animator.isUserInteractionEnabled = true
animator.addCompletion { _ in
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
}
return animator
}
}
final class DismissTransitioning: NSObject, UIViewControllerAnimatedTransitioning {
private var cachedAnimator: UIViewImplicitlyAnimating?
private let duration: TimeInterval
init(duration: TimeInterval) {
self.duration = duration
}
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return self.duration
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
guard let animator = self.animator(using: transitionContext) else {
return
}
animator.startAnimation()
}
func interruptibleAnimator(using transitionContext: UIViewControllerContextTransitioning) -> UIViewImplicitlyAnimating {
if let cachedAnimator {
return cachedAnimator
}
if let animator = self.animator(using: transitionContext, needsResetCache: true) {
self.cachedAnimator = animator
return animator
}
return UIViewPropertyAnimator()
}
private func animator(using transitionContext: UIViewControllerContextTransitioning,
needsResetCache: Bool = false) -> UIViewImplicitlyAnimating? {
let containerSubviews = transitionContext.containerView.subviews
guard let from = transitionContext.viewController(forKey: .from),
let toView = transitionContext.containerView.subviews[safeIndex: containerSubviews.count - 2] else {
transitionContext.completeTransition(false)
return nil
}
let initialFrame = transitionContext.initialFrame(for: from)
let spring = UISpringTimingParameters()
let animator = UIViewPropertyAnimator(duration: self.duration,
timingParameters: spring)
animator.addAnimations {
from.view.frame = initialFrame.offsetBy(dx: 0, dy: initialFrame.height)
}
animator.addAnimations {
toView.transform = .identity
}
animator.isInterruptible = true
animator.isUserInteractionEnabled = true
animator.addCompletion { position in
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
if needsResetCache {
self.cachedAnimator = nil
}
}
return animator
}
}
final class PresentationController: UIPresentationController,
UIGestureRecognizerDelegate {
private lazy var panRecognizer = OneWayPanGestureRecognizer(target: self,
action: #selector(handlePanGesture(_:)),
direction: .fromTopToBottom)
private let interactiveController: UIPercentDrivenInteractiveTransition
private var presentingViewSnapshot: UIView?
init(presentedViewController: UIViewController,
presenting presentingViewController: UIViewController?,
interactiveController: UIPercentDrivenInteractiveTransition) {
self.interactiveController = interactiveController
super.init(presentedViewController: presentedViewController,
presenting: presentingViewController)
self.panRecognizer.delegate = self
}
override var shouldRemovePresentersView: Bool {
return true
}
override func presentationTransitionWillBegin() {
super.presentationTransitionWillBegin()
guard let containerView,
let presentedView,
let presentingView = self.presentingViewController.view,
let presentingViewSnapshot = presentingView.snapshotView(afterScreenUpdates: true) else {
return
}
let maskedCorners: CACornerMask = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
let displayCornerRadius = UIScreen.main.displayCornerRadius
presentedView.layer.maskedCorners = maskedCorners
presentedView.layer.cornerRadius = displayCornerRadius
presentedView.layer.masksToBounds = true
presentingViewSnapshot.layer.maskedCorners = maskedCorners
presentingViewSnapshot.layer.cornerRadius = displayCornerRadius
presentingViewSnapshot.layer.masksToBounds = true
self.presentingViewSnapshot = presentingViewSnapshot
presentedView.addGestureRecognizer(self.panRecognizer)
containerView.addSubview(presentingViewSnapshot)
containerView.addSubview(presentedView)
presentingView.isHidden = true
}
override func presentationTransitionDidEnd(_ completed: Bool) {
super.presentationTransitionDidEnd(completed)
guard completed,
let presentedView,
let scrollView = presentedView.subviews.first(where: { view in
view is UIScrollView
}) as? UIScrollView else {
return
}
scrollView.panGestureRecognizer.require(toFail: self.panRecognizer)
}
override func dismissalTransitionDidEnd(_ completed: Bool) {
super.dismissalTransitionDidEnd(completed)
guard completed,
let presentedView,
let presentingView = self.presentingViewController.view,
let presentingViewSnapshot else {
return
}
presentingView.isHidden = false
presentingViewSnapshot.removeFromSuperview()
presentedView.removeFromSuperview()
}
// MARK: - UIGestureRecognizerDelegate
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
if let scrollView = self.presentedView?.subviews.first(where: { view in
view is UIScrollView
}) as? UIScrollView {
return scrollView.contentOffset.y <= 0
}
return true
}
private func performAlongsideTransitionIfPossible(_ block: @escaping () -> Void) {
guard let coordinator = self.presentedViewController.transitionCoordinator else {
block()
return
}
coordinator.animate(alongsideTransition: { _ in
block()
}, completion: nil)
}
@objc
private func handlePanGesture(_ recognizer: UIPanGestureRecognizer) {
guard let view = recognizer.view else {
return
}
switch recognizer.state {
case .possible, .began:
self.presentedViewController.dismiss(animated: true)
case .changed:
let translation = recognizer.translation(in: view).y
let velocity = recognizer.velocity(in: view).y
let progress = self.progress(from: translation,
velocity: velocity,
viewHeight: view.bounds.height,
hasEnded: false)
self.interactiveController.update(progress)
case .cancelled, .failed:
self.interactiveController.completionSpeed = 0.7;
self.interactiveController.cancel()
case .ended:
let translation = recognizer.translation(in: view).y
let velocity = recognizer.velocity(in: view).y
let progress = self.progress(from: translation,
velocity: velocity,
viewHeight: view.bounds.height,
hasEnded: true)
if progress > 0.4 || velocity > 1000 {
self.interactiveController.finish()
} else {
self.interactiveController.completionSpeed = 0.7;
self.interactiveController.cancel()
}
@unknown default:
break
}
}
func progress(from translation: CGFloat,
velocity: CGFloat,
viewHeight: CGFloat,
hasEnded: Bool) -> CGFloat {
var translation = translation
if hasEnded {
let decelerationRate = 0.95
let distance = (velocity / 1000) * decelerationRate / (1 - decelerationRate)
translation += distance
}
return max(min(translation / viewHeight, 1), 0)
}
}
enum OneWayPanGestureRecognizerDirection {
case fromTopToBottom
case fromBottomToTop
case fromLeftToRight
case fromRightToLeft
}
final class OneWayPanGestureRecognizer: UIPanGestureRecognizer {
private var moveX: CGFloat = 0
private var moveY: CGFloat = 0
private let direction: OneWayPanGestureRecognizerDirection
init(target: Any?, action: Selector?, direction: OneWayPanGestureRecognizerDirection) {
self.direction = direction
super.init(target: target, action: action)
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent) {
super.touchesMoved(touches, with: event)
guard self.state != .failed,
let touch = touches.first else {
return
}
let newPoint = touch.location(in: self.view)
let previousPoint = touch.previousLocation(in: self.view)
self.moveX += previousPoint.x - newPoint.x
self.moveY += previousPoint.y - newPoint.y
switch direction {
case .fromTopToBottom:
if self.moveY > 0 {
self.state = .failed
}
case .fromBottomToTop:
if self.moveY < 0 {
self.state = .failed
}
case .fromLeftToRight:
if self.moveX > 0 {
self.state = .failed
}
case .fromRightToLeft:
if self.moveX < 0 {
self.state = .failed
}
}
}
override func reset() {
super.reset()
self.moveX = 0
self.moveY = 0
}
}
extension Collection {
@inlinable subscript(safeIndex index: Index) -> Element? {
self.indices.contains(index) ? self[index] : nil
}
}
extension UIScreen {
var displayCornerRadius: CGFloat {
let key = "_displayCornerRadius"
let cornerRadius = self.value(forKey: key) as! CGFloat
return cornerRadius
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment