Created
February 17, 2024 16:50
-
-
Save ertembiyik/1a0d7002c80319e82c2e9b3ddc6773d7 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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