// // GalleryDismissAnimationController.swift // GalleryVC // // Created by Shadowfacts on 3/1/24. // import UIKit class GalleryDismissAnimationController: NSObject, UIViewControllerAnimatedTransitioning { private let sourceView: UIView private let interactiveTranslation: CGPoint? private let interactiveVelocity: CGPoint? init(sourceView: UIView, interactiveTranslation: CGPoint?, interactiveVelocity: CGPoint?) { self.sourceView = sourceView self.interactiveTranslation = interactiveTranslation self.interactiveVelocity = interactiveVelocity } func transitionDuration(using transitionContext: (any UIViewControllerContextTransitioning)?) -> TimeInterval { return 0.3 } func animateTransition(using transitionContext: any UIViewControllerContextTransitioning) { guard let to = transitionContext.viewController(forKey: .to), let from = transitionContext.viewController(forKey: .from) as? GalleryViewController else { fatalError() } let itemViewController = from.currentItemViewController if !itemViewController.content.canAnimateFromSourceView || (UIAccessibility.prefersCrossFadeTransitions && interactiveVelocity == nil) { animateCrossFadeTransition(using: transitionContext) return } let container = transitionContext.containerView let sourceFrameInContainer = container.convert(sourceView.bounds, from: sourceView) let destFrameInContainer = container.convert(itemViewController.content.view.bounds, from: itemViewController.content.view) let origSourceTransform = sourceView.transform let appliedSourceToDestTransform: Bool if destFrameInContainer.width > 0 && destFrameInContainer.height > 0 { appliedSourceToDestTransform = true let scale = min(destFrameInContainer.width / sourceFrameInContainer.width, destFrameInContainer.height / sourceFrameInContainer.height) let sourceToDestTransform = origSourceTransform .translatedBy(x: destFrameInContainer.midX - sourceFrameInContainer.midX, y: destFrameInContainer.midY - sourceFrameInContainer.midY) .scaledBy(x: scale, y: scale) sourceView.transform = sourceToDestTransform } else { appliedSourceToDestTransform = false } // Moving `to.view` to the container is necessary when the presenting VC (i.e., `to`) // is in the window's root presentation. // But it breaks when the gallery is presented from a sheet-presented VC--in which case // `to.view` is already in the view hierarchy at this point; and adding it to the // container causees it to be removed when the transition completes. if to.view.superview == nil { to.view.frame = container.bounds container.addSubview(to.view) } from.view.frame = container.bounds container.addSubview(from.view) let content = itemViewController.takeContent() content.view.translatesAutoresizingMaskIntoConstraints = true content.view.layer.masksToBounds = true container.addSubview(content.view) content.view.frame = destFrameInContainer content.view.layer.opacity = 1 container.layoutIfNeeded() let duration = self.transitionDuration(using: transitionContext) var initialVelocity: CGVector if let interactiveVelocity, let interactiveTranslation, // very short/fast flicks don't transfer their velocity, since it makes size change animation look wacky due to the springs initial undershoot sqrt(pow(interactiveTranslation.x, 2) + pow(interactiveTranslation.y, 2)) > 100, sqrt(pow(interactiveVelocity.x, 2) + pow(interactiveVelocity.y, 2)) < 2000 { let xDistance = sourceFrameInContainer.midX - destFrameInContainer.midX let yDistance = sourceFrameInContainer.midY - destFrameInContainer.midY initialVelocity = CGVector( dx: xDistance == 0 ? 0 : interactiveVelocity.x / xDistance, dy: yDistance == 0 ? 0 : interactiveVelocity.y / yDistance ) } else { initialVelocity = .zero } initialVelocity.dx = max(-10, min(10, initialVelocity.dx)) initialVelocity.dy = max(-10, min(10, initialVelocity.dy)) // no bounce for the dismiss animation let spring = UISpringTimingParameters(mass: 1, stiffness: 439, damping: 42, initialVelocity: initialVelocity) let animator = UIViewPropertyAnimator(duration: duration, timingParameters: spring) animator.addAnimations { from.view.layer.opacity = 0 if appliedSourceToDestTransform { self.sourceView.transform = origSourceTransform } content.view.frame = sourceFrameInContainer content.view.layer.opacity = 0 itemViewController.setControlsVisible(false, animated: false, dueToUserInteraction: false) } animator.addCompletion { _ in transitionContext.completeTransition(true) } animator.startAnimation() } private func animateCrossFadeTransition(using transitionContext: UIViewControllerContextTransitioning) { guard let fromVC = transitionContext.viewController(forKey: .from), let toVC = transitionContext.viewController(forKey: .to) else { return } toVC.view.frame = transitionContext.containerView.bounds fromVC.view.frame = transitionContext.containerView.bounds transitionContext.containerView.addSubview(toVC.view) transitionContext.containerView.addSubview(fromVC.view) let duration = transitionDuration(using: transitionContext) let animator = UIViewPropertyAnimator(duration: duration, curve: .easeInOut) animator.addAnimations { fromVC.view.alpha = 0 } animator.addCompletion { _ in transitionContext.completeTransition(!transitionContext.transitionWasCancelled) } animator.startAnimation() } }