Path: blob/main/Signal/src/ViewControllers/MediaGallery/Transitions/MediaDismissAnimationController.swift
1 views
//
// Copyright 2019 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import SignalServiceKit
import SignalUI
class MediaDismissAnimationController: NSObject {
private let item: Media
let interactionController: MediaInteractiveDismiss
var transitionView: UIView?
var fromMediaFrame: CGRect?
var pendingCompletion: (() -> Void)?
init(galleryItem: MediaGalleryItem, interactionController: MediaInteractiveDismiss) {
self.item = .gallery(galleryItem)
self.interactionController = interactionController
}
init(image: UIImage, interactionController: MediaInteractiveDismiss) {
self.item = .image(image)
self.interactionController = interactionController
}
}
extension MediaDismissAnimationController: UIViewControllerAnimatedTransitioning {
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return MediaPresentationContext.animationDuration
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
let containerView = transitionContext.containerView
// Bunch of checks to ensure everything is set up for the animated transition.
// If there's anything wrong the transition would complete without animation.
guard let fromVC = transitionContext.viewController(forKey: .from) else {
owsFailDebug("fromVC was unexpectedly nil")
transitionContext.completeTransition(false)
return
}
let fromContextProvider: MediaPresentationContextProvider
switch fromVC {
case let contextProvider as MediaPresentationContextProvider:
fromContextProvider = contextProvider
case let navController as UINavigationController:
guard let contextProvider = navController.topViewController as? MediaPresentationContextProvider else {
owsFailDebug("unexpected context: \(String(describing: navController.topViewController))")
transitionContext.completeTransition(false)
return
}
fromContextProvider = contextProvider
default:
owsFailDebug("unexpected fromVC: \(fromVC)")
transitionContext.completeTransition(false)
return
}
guard let fromMediaContext = fromContextProvider.mediaPresentationContext(item: item, in: containerView) else {
owsFailDebug("fromPresentationContext was unexpectedly nil")
transitionContext.completeTransition(false)
return
}
self.fromMediaFrame = fromMediaContext.presentationFrame
guard let toVC = transitionContext.viewController(forKey: .to) else {
owsFailDebug("toVC was unexpectedly nil")
transitionContext.completeTransition(false)
return
}
// toView will be nil if doing a modal dismiss, in which case we don't want to add the view -
// it's already in the view hierarchy, behind the VC we're dismissing.
if let toView = transitionContext.view(forKey: .to) {
containerView.addSubview(toView)
}
guard let fromView = transitionContext.view(forKey: .from) else {
owsFailDebug("fromView was unexpectedly nil")
transitionContext.completeTransition(false)
return
}
let toContextProvider: MediaPresentationContextProvider
switch toVC {
case let contextProvider as MediaPresentationContextProvider:
toContextProvider = contextProvider
case let navController as UINavigationController:
guard let contextProvider = navController.topViewController as? MediaPresentationContextProvider else {
owsFailDebug("unexpected context: \(String(describing: navController.topViewController))")
transitionContext.completeTransition(false)
return
}
toContextProvider = contextProvider
case let splitViewController as ConversationSplitViewController:
guard let contextProvider = splitViewController.topViewController as? MediaPresentationContextProvider else {
owsFailDebug("unexpected context: \(String(describing: splitViewController.topViewController))")
transitionContext.completeTransition(false)
return
}
toContextProvider = contextProvider
default:
owsFailDebug("unexpected toVC: \(toVC)")
transitionContext.completeTransition(false)
return
}
let toMediaContext = toContextProvider.mediaPresentationContext(item: item, in: containerView)
guard let presentationImage = item.image else {
owsFailDebug("presentationImage was unexpectedly nil")
// Complete transition immediately.
fromContextProvider.mediaWillPresent(fromContext: fromMediaContext)
if let toMediaContext {
toContextProvider.mediaWillPresent(toContext: toMediaContext)
}
DispatchQueue.main.async {
fromContextProvider.mediaDidPresent(fromContext: fromMediaContext)
if let toMediaContext {
toContextProvider.mediaDidPresent(toContext: toMediaContext)
}
transitionContext.completeTransition(true)
}
return
}
// All is good, set up the view hierarchy and view animations.
let isTransitionInteractive = transitionContext.isInteractive
let backgroundView = UIView(frame: containerView.bounds)
backgroundView.backgroundColor = fromMediaContext.backgroundColor
backgroundView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
containerView.addSubview(backgroundView)
// Sometimes the initial (from) or the final (to) media view is partially obscured
// (by navigation bar at the top and by the bottom bar at the bottom).
// To animate from one "viewport" to another we set up a clipping
// view that will contain the transitional media view.
let clippingView = UIView(frame: containerView.bounds)
clippingView.clipsToBounds = true
if let clippingAreaInsets = fromMediaContext.clippingAreaInsets {
clippingView.frame = containerView.bounds.inset(by: clippingAreaInsets)
}
containerView.addSubview(clippingView)
// Can't do rounded corners and drop shadow at the same time,
// so put image into a container view.
let transitionViewFrame = clippingView.convert(fromMediaContext.presentationFrame, from: containerView)
let transitionView = UIView(frame: transitionViewFrame)
transitionView.layer.shadowColor = UIColor.ows_blackAlpha20.cgColor
transitionView.layer.shadowOffset = CGSize(width: 0, height: 32)
transitionView.layer.shadowRadius = 48
transitionView.layer.shadowOpacity = 0
clippingView.addSubview(transitionView)
self.transitionView = transitionView
let imageView = MediaTransitionImageView(image: presentationImage)
imageView.contentMode = .scaleAspectFill
imageView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
imageView.layer.masksToBounds = true
imageView.shape = fromMediaContext.mediaViewShape
imageView.frame = transitionView.bounds
transitionView.addSubview(imageView)
// Because toggling `isHidden` causes UIStack view layouts to change, we instead toggle `alpha`
fromMediaContext.mediaView.alpha = 0
toMediaContext?.mediaView.alpha = 0
let duration = transitionDuration(using: transitionContext)
let completion = {
let destinationFrame: CGRect
let destinationMediaViewShape: MediaViewShape
if transitionContext.transitionWasCancelled {
destinationFrame = fromMediaContext.presentationFrame
destinationMediaViewShape = fromMediaContext.mediaViewShape
} else if let toMediaContext {
destinationFrame = toMediaContext.presentationFrame
destinationMediaViewShape = toMediaContext.mediaViewShape
} else {
// `toMediaContext` can be nil if the target item is scrolled off of the
// contextProvider's screen, so we synthesize a context to dismiss the item
// off screen
let offscreenFrame = fromMediaContext.presentationFrame.offsetBy(dx: 0, dy: fromMediaContext.presentationFrame.height)
destinationFrame = offscreenFrame
destinationMediaViewShape = fromMediaContext.mediaViewShape
}
let animator = UIViewPropertyAnimator(
duration: duration,
springDamping: 1,
springResponse: 0.25,
)
animator.addAnimations {
if transitionContext.transitionWasCancelled == false {
fromView.alpha = 0
backgroundView.backgroundColor = toMediaContext?.backgroundColor
if let clippingAreaInsets = toMediaContext?.clippingAreaInsets {
clippingView.frame = containerView.bounds.inset(by: clippingAreaInsets)
} else {
clippingView.frame = containerView.bounds
}
}
imageView.shape = destinationMediaViewShape
transitionView.transform = .identity
transitionView.frame = clippingView.convert(destinationFrame, from: containerView)
transitionView.layer.shadowOpacity = 0
}
animator.addCompletion { _ in
clippingView.removeFromSuperview()
backgroundView.removeFromSuperview()
fromMediaContext.mediaView.alpha = 1
toMediaContext?.mediaView.alpha = 1
if transitionContext.transitionWasCancelled {
// the "to" view will be nil if we're doing a modal dismiss, in which case
// we wouldn't want to remove the toView.
transitionContext.view(forKey: .to)?.removeFromSuperview()
} else {
assert(transitionContext.view(forKey: .from) != nil)
transitionContext.view(forKey: .from)?.removeFromSuperview()
}
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
DispatchQueue.main.async {
fromContextProvider.mediaDidDismiss(fromContext: fromMediaContext)
if let toMediaContext {
toContextProvider.mediaDidDismiss(toContext: toMediaContext)
}
}
}
animator.startAnimation()
}
fromContextProvider.mediaWillDismiss(fromContext: fromMediaContext)
if let toMediaContext {
toContextProvider.mediaWillDismiss(toContext: toMediaContext)
}
if isTransitionInteractive {
self.pendingCompletion = completion
// "animation end" state is the UI state when user drags the image around
// and has exceeded distance threshold specified in MediaInteractiveDismiss.
// UIKit will reverse the animation if user drags the image back to the starting point.
UIView.animate(
withDuration: 0.2,
delay: 0,
animations: {
fromView.alpha = 0
backgroundView.backgroundColor = toMediaContext?.backgroundColor
transitionView.transform = .scale(0.8)
transitionView.layer.shadowOpacity = 1
},
completion: { _ in
guard let pendingCompletion = self.pendingCompletion else {
return
}
self.pendingCompletion = nil
pendingCompletion()
},
)
} else {
completion()
}
}
}
extension MediaDismissAnimationController: InteractiveDismissDelegate {
func interactiveDismissDidBegin(_ interactiveDismiss: UIPercentDrivenInteractiveTransition) { }
func interactiveDismiss(
_ interactiveDismiss: UIPercentDrivenInteractiveTransition,
didChangeProgress progress: CGFloat,
touchOffset offset: CGPoint,
) {
guard let transitionView else {
// transition hasn't started yet.
return
}
guard let fromMediaFrame else {
owsFailDebug("fromMediaFrame was unexpectedly nil")
return
}
transitionView.center = fromMediaFrame.offsetBy(dx: offset.x, dy: offset.y).center
}
func interactiveDismissDidFinish(_ interactiveDismiss: UIPercentDrivenInteractiveTransition) {
if let pendingCompletion {
self.pendingCompletion = nil
pendingCompletion()
}
}
func interactiveDismissDidCancel(_ interactiveDismiss: UIPercentDrivenInteractiveTransition) { }
}