Tusker/Packages/GalleryVC/Sources/GalleryVC/GalleryDismissAnimationController.swift

194 lines
8.4 KiB
Swift

//
// 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.presentationAnimation == .fade || (UIAccessibility.prefersCrossFadeTransitions && interactiveVelocity == nil) {
animateCrossFadeTransition(using: transitionContext)
return
}
let container = transitionContext.containerView
// 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)
}
let sourceSnapshot: UIView? = if itemViewController.content.presentationAnimation == .fromSourceViewWithoutSnapshot {
nil
} else {
sourceView.snapshotView(afterScreenUpdates: false)
}
if let sourceSnapshot {
let snapshotContainer = sourceView.ancestorForInsertingSnapshot
snapshotContainer.addSubview(sourceSnapshot)
let sourceFrameInShapshotContainer = snapshotContainer.convert(sourceView.bounds, from: sourceView)
sourceSnapshot.frame = sourceFrameInShapshotContainer
sourceSnapshot.layer.opacity = 1
self.sourceView.layer.opacity = 0
}
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
sourceSnapshot?.transform = sourceToDestTransform
} else {
appliedSourceToDestTransform = false
}
from.view.frame = container.bounds
container.addSubview(from.view)
let contentContainer = UIView()
contentContainer.layer.masksToBounds = true
contentContainer.frame = destFrameInContainer
container.addSubview(contentContainer)
let content = itemViewController.takeContent()
content.view.translatesAutoresizingMaskIntoConstraints = true
content.view.transform = .identity
content.view.layer.opacity = 1
content.view.frame = contentContainer.bounds
contentContainer.addSubview(content.view)
container.layoutIfNeeded()
// Hide overlaid controls immediately, to prevent the Live Text button's position
// getting caught up in the rest of the animation.
UIView.animate(withDuration: 0.1) {
content.setControlsVisible(false, animated: false, dueToUserInteraction: false)
}
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 spring's 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
sourceSnapshot?.transform = origSourceTransform
}
contentContainer.frame = sourceFrameInContainer
// Using sourceSizeWithDestAspectRatioCenteredInContentContainer does not seem to be necessary here.
// I guess autoresizing takes care of it?
itemViewController.setControlsVisible(false, animated: false, dueToUserInteraction: false)
}
// Delay fading out the content because if it's still big while it's semi-transparent,
// seeing the stuff behind it looks odd.
animator.addAnimations({
content.view.layer.opacity = 0
}, delayFactor: 0.35)
if let sourceSnapshot {
animator.addAnimations({
self.sourceView.layer.opacity = 1
sourceSnapshot.layer.opacity = 0
}, delayFactor: 0.5)
}
animator.addCompletion { _ in
sourceSnapshot?.removeFromSuperview()
// Having dismissed, we don't need to undo any of the changes to the content VC.
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
}
let container = transitionContext.containerView
// See comment above.
if toVC.view.superview == nil {
toVC.view.frame = container.bounds
container.addSubview(toVC.view)
}
toVC.view.frame = transitionContext.containerView.bounds
fromVC.view.frame = transitionContext.containerView.bounds
container.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()
}
}