// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit import SessionUIKit class MediaZoomAnimationController: NSObject { private let mediaItem: Media private let shouldBounce: Bool init(galleryItem: MediaGalleryViewModel.Item, shouldBounce: Bool = true) { self.mediaItem = .gallery(galleryItem) self.shouldBounce = shouldBounce } } extension MediaZoomAnimationController: UIViewControllerAnimatedTransitioning { func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { return 0.4 } func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { let containerView = transitionContext.containerView let fromContextProvider: MediaPresentationContextProvider let toContextProvider: MediaPresentationContextProvider guard let fromVC: UIViewController = transitionContext.viewController(forKey: .from) else { transitionContext.completeTransition(false) return } guard let toVC: UIViewController = transitionContext.viewController(forKey: .to) else { transitionContext.completeTransition(false) return } switch fromVC { case let contextProvider as MediaPresentationContextProvider: fromContextProvider = contextProvider case let topBannerController as TopBannerController: guard let firstChild: UIViewController = topBannerController.children.first, let navController: UINavigationController = firstChild as? UINavigationController, let contextProvider = navController.topViewController as? MediaPresentationContextProvider else { transitionContext.completeTransition(false) return } fromContextProvider = contextProvider case let navController as UINavigationController: guard let contextProvider = navController.topViewController as? MediaPresentationContextProvider else { transitionContext.completeTransition(false) return } fromContextProvider = contextProvider default: transitionContext.completeTransition(false) return } switch toVC { case let contextProvider as MediaPresentationContextProvider: toContextProvider = contextProvider case let topBannerController as TopBannerController: guard let firstChild: UIViewController = topBannerController.children.first, let navController: UINavigationController = firstChild as? UINavigationController, let contextProvider = navController.topViewController as? MediaPresentationContextProvider else { transitionContext.completeTransition(false) return } toContextProvider = contextProvider case let navController as UINavigationController: guard let contextProvider = navController.topViewController as? MediaPresentationContextProvider else { transitionContext.completeTransition(false) return } toContextProvider = contextProvider default: transitionContext.completeTransition(false) return } // 'view(forKey: .to)' will be nil when using this transition for a modal dismiss, in which // case we want to use the 'toVC.view' but need to ensure we add it back to it's original // parent afterwards so we don't break the view hierarchy // // Note: We *MUST* call 'layoutIfNeeded' prior to 'toContextProvider.mediaPresentationContext' // as the 'toContextProvider.mediaPresentationContext' is dependant on it having the correct // positioning (and the navBar sizing isn't correct until after layout) let toView: UIView = (transitionContext.view(forKey: .to) ?? toVC.view) let duration: CGFloat = transitionDuration(using: transitionContext) let oldToViewSuperview: UIView? = toView.superview toView.layoutIfNeeded() // If we can't retrieve the contextual info we need to perform the proper zoom animation then // just fade the destination in (otherwise the user would get stuck on a blank screen) guard let fromMediaContext: MediaPresentationContext = fromContextProvider.mediaPresentationContext(mediaItem: mediaItem, in: containerView), let toMediaContext: MediaPresentationContext = toContextProvider.mediaPresentationContext(mediaItem: mediaItem, in: containerView), let presentationImage: UIImage = mediaItem.image else { toView.frame = containerView.bounds toView.alpha = 0 containerView.addSubview(toView) UIView.animate( withDuration: (duration / 2), delay: 0, options: .curveEaseInOut, animations: { toView.alpha = 1 }, completion: { _ in // Need to ensure we add the 'toView' back to it's old superview if it had one oldToViewSuperview?.addSubview(toView) transitionContext.completeTransition(!transitionContext.transitionWasCancelled) } ) return } fromMediaContext.mediaView.alpha = 0 toMediaContext.mediaView.alpha = 0 toView.frame = containerView.bounds toView.alpha = 0 containerView.addSubview(toView) let transitionView: UIImageView = UIImageView(image: presentationImage) transitionView.frame = fromMediaContext.presentationFrame transitionView.contentMode = MediaView.contentMode transitionView.layer.masksToBounds = true transitionView.layer.cornerRadius = fromMediaContext.cornerRadius transitionView.layer.maskedCorners = fromMediaContext.cornerMask containerView.addSubview(transitionView) // Note: We need to do this after adding the 'transitionView' and insert it at the back // otherwise the screen can flicker since we have 'afterScreenUpdates: true' (if we use // 'afterScreenUpdates: false' then the 'fromMediaContext.mediaView' won't be hidden // during the transition) let fromSnapshotView: UIView = (fromVC.view.snapshotView(afterScreenUpdates: true) ?? UIView()) containerView.insertSubview(fromSnapshotView, at: 0) let overshootPercentage: CGFloat = 0.15 let overshootFrame: CGRect = (self.shouldBounce ? CGRect( x: (toMediaContext.presentationFrame.minX + ((toMediaContext.presentationFrame.minX - fromMediaContext.presentationFrame.minX) * overshootPercentage)), y: (toMediaContext.presentationFrame.minY + ((toMediaContext.presentationFrame.minY - fromMediaContext.presentationFrame.minY) * overshootPercentage)), width: (toMediaContext.presentationFrame.width + ((toMediaContext.presentationFrame.width - fromMediaContext.presentationFrame.width) * overshootPercentage)), height: (toMediaContext.presentationFrame.height + ((toMediaContext.presentationFrame.height - fromMediaContext.presentationFrame.height) * overshootPercentage)) ) : toMediaContext.presentationFrame ) // Add any UI elements which should appear above the media view let fromTransitionalOverlayView: UIView? = { guard let (overlayView, overlayViewFrame) = fromContextProvider.snapshotOverlayView(in: containerView) else { return nil } overlayView.frame = overlayViewFrame containerView.addSubview(overlayView) return overlayView }() let toTransitionalOverlayView: UIView? = { guard let (overlayView, overlayViewFrame) = toContextProvider.snapshotOverlayView(in: containerView) else { return nil } overlayView.alpha = 0 overlayView.frame = overlayViewFrame containerView.addSubview(overlayView) return overlayView }() UIView.animate( withDuration: (duration / 2), delay: 0, options: .curveEaseOut, animations: { // Only fade out the 'fromTransitionalOverlayView' if it's bigger than the destination // one (makes it look cleaner as you don't get the crossfade effect) if (fromTransitionalOverlayView?.frame.size.height ?? 0) > (toTransitionalOverlayView?.frame.size.height ?? 0) { fromTransitionalOverlayView?.alpha = 0 } toView.alpha = 1 toTransitionalOverlayView?.alpha = 1 transitionView.frame = overshootFrame transitionView.layer.cornerRadius = toMediaContext.cornerRadius }, completion: { _ in UIView.animate( withDuration: (duration / 2), delay: 0, options: .curveEaseInOut, animations: { transitionView.frame = toMediaContext.presentationFrame }, completion: { _ in transitionView.removeFromSuperview() fromSnapshotView.removeFromSuperview() fromTransitionalOverlayView?.removeFromSuperview() toTransitionalOverlayView?.removeFromSuperview() toMediaContext.mediaView.alpha = 1 fromMediaContext.mediaView.alpha = 1 // Need to ensure we add the 'toView' back to it's old superview if it had one oldToViewSuperview?.addSubview(toView) transitionContext.completeTransition(!transitionContext.transitionWasCancelled) } ) } ) } }