From 8de227d7804268082d5b0dea8c116e26e6d3608d Mon Sep 17 00:00:00 2001 From: Justin Mazzocchi <2831158+jzzocc@users.noreply.github.com> Date: Wed, 21 Oct 2020 22:05:50 -0700 Subject: [PATCH] Zoom transition --- Metatext.xcodeproj/project.pbxproj | 24 +++ Transitions/ZoomAnimatableView.swift | 43 +++++ Transitions/ZoomAnimator.swift | 128 +++++++++++++++ .../ZoomDismissalInteractionController.swift | 154 ++++++++++++++++++ Transitions/ZoomTransitionController.swift | 79 +++++++++ .../ImageNavigationController.swift | 89 ++++++++++ View Controllers/ImageViewController.swift | 9 +- View Controllers/TableViewController.swift | 36 +++- .../ViewModels/AttachmentViewModel.swift | 9 +- .../Sources/ViewModels/StatusViewModel.swift | 2 +- Views/Status/StatusAttachmentView.swift | 5 + 11 files changed, 570 insertions(+), 8 deletions(-) create mode 100644 Transitions/ZoomAnimatableView.swift create mode 100644 Transitions/ZoomAnimator.swift create mode 100644 Transitions/ZoomDismissalInteractionController.swift create mode 100644 Transitions/ZoomTransitionController.swift diff --git a/Metatext.xcodeproj/project.pbxproj b/Metatext.xcodeproj/project.pbxproj index 6c2df18..27f36db 100644 --- a/Metatext.xcodeproj/project.pbxproj +++ b/Metatext.xcodeproj/project.pbxproj @@ -22,6 +22,10 @@ D08B8D3D253F929E00B1EBEF /* ImageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08B8D3C253F929E00B1EBEF /* ImageViewController.swift */; }; D08B8D42253F92B600B1EBEF /* ImagePageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08B8D41253F92B600B1EBEF /* ImagePageViewController.swift */; }; D08B8D4A253FC36500B1EBEF /* ImageNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08B8D49253FC36500B1EBEF /* ImageNavigationController.swift */; }; + D08B8D602540DE3B00B1EBEF /* ZoomAnimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08B8D5D2540DE3A00B1EBEF /* ZoomAnimator.swift */; }; + D08B8D612540DE3B00B1EBEF /* ZoomDismissalInteractionController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08B8D5E2540DE3A00B1EBEF /* ZoomDismissalInteractionController.swift */; }; + D08B8D622540DE3B00B1EBEF /* ZoomTransitionController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08B8D5F2540DE3A00B1EBEF /* ZoomTransitionController.swift */; }; + D08B8D672540DEB200B1EBEF /* ZoomAnimatableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08B8D662540DEB200B1EBEF /* ZoomAnimatableView.swift */; }; D0A1F4F7252E7D4B004435BF /* TableViewDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A1F4F6252E7D4B004435BF /* TableViewDataSource.swift */; }; D0A3C2F725390A9700739F88 /* AppPreferences+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A3C2F625390A9700739F88 /* AppPreferences+Extensions.swift */; }; D0B32F50250B373600311912 /* RegistrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B32F4F250B373600311912 /* RegistrationView.swift */; }; @@ -122,6 +126,10 @@ D08B8D3C253F929E00B1EBEF /* ImageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageViewController.swift; sourceTree = ""; }; D08B8D41253F92B600B1EBEF /* ImagePageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePageViewController.swift; sourceTree = ""; }; D08B8D49253FC36500B1EBEF /* ImageNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageNavigationController.swift; sourceTree = ""; }; + D08B8D5D2540DE3A00B1EBEF /* ZoomAnimator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ZoomAnimator.swift; sourceTree = ""; }; + D08B8D5E2540DE3A00B1EBEF /* ZoomDismissalInteractionController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ZoomDismissalInteractionController.swift; sourceTree = ""; }; + D08B8D5F2540DE3A00B1EBEF /* ZoomTransitionController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ZoomTransitionController.swift; sourceTree = ""; }; + D08B8D662540DEB200B1EBEF /* ZoomAnimatableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ZoomAnimatableView.swift; sourceTree = ""; }; D0A1F4F6252E7D4B004435BF /* TableViewDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewDataSource.swift; sourceTree = ""; }; D0A3C2F625390A9700739F88 /* AppPreferences+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppPreferences+Extensions.swift"; sourceTree = ""; }; D0AD03552505814D0085A466 /* Base16 */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Base16; sourceTree = ""; }; @@ -234,6 +242,7 @@ D0C7D41D24F76169001EBDBB /* Supporting Files */, D0C7D45324F76169001EBDBB /* System */, D0666A2224C677B400F3F04B /* Tests */, + D08B8D5C2540DDFC00B1EBEF /* Transitions */, D0C7D43024F76169001EBDBB /* View Controllers */, D0E2C1CF24FD8BA400854680 /* ViewModels */, D0C7D42024F76169001EBDBB /* Views */, @@ -278,6 +287,17 @@ name = Frameworks; sourceTree = ""; }; + D08B8D5C2540DDFC00B1EBEF /* Transitions */ = { + isa = PBXGroup; + children = ( + D08B8D662540DEB200B1EBEF /* ZoomAnimatableView.swift */, + D08B8D5D2540DE3A00B1EBEF /* ZoomAnimator.swift */, + D08B8D5E2540DE3A00B1EBEF /* ZoomDismissalInteractionController.swift */, + D08B8D5F2540DE3A00B1EBEF /* ZoomTransitionController.swift */, + ); + path = Transitions; + sourceTree = ""; + }; D0A1F4F5252E7D2A004435BF /* Data Sources */ = { isa = PBXGroup; children = ( @@ -569,12 +589,14 @@ D0C7D49C24F7616A001EBDBB /* RootView.swift in Sources */, D0F0B126251A90F400942152 /* AccountListCell.swift in Sources */, D0B32F50250B373600311912 /* RegistrationView.swift in Sources */, + D08B8D612540DE3B00B1EBEF /* ZoomDismissalInteractionController.swift in Sources */, D0E569E0252931B100FA1D72 /* LoadMoreContentConfiguration.swift in Sources */, D0FE1C9825368A9D003EF1EB /* PlayerCache.swift in Sources */, D0F0B136251AA12700942152 /* CollectionItem+Extensions.swift in Sources */, D0625E5D250F0B5C00502611 /* StatusContentConfiguration.swift in Sources */, D0BEB1F324F8EE8C001B0F04 /* StatusAttachmentView.swift in Sources */, D0C7D49A24F7616A001EBDBB /* TableView.swift in Sources */, + D08B8D622540DE3B00B1EBEF /* ZoomTransitionController.swift in Sources */, D0F0B12E251A97E400942152 /* TableViewController.swift in Sources */, D0FE1C8F253686F9003EF1EB /* PlayerView.swift in Sources */, D0F0B113251A86A000942152 /* AccountContentConfiguration.swift in Sources */, @@ -586,6 +608,8 @@ D0C7D49D24F7616A001EBDBB /* PostingReadingPreferencesView.swift in Sources */, D0B5FE9B251583DB00478838 /* ProfileCollection+Extensions.swift in Sources */, D0C7D49E24F7616A001EBDBB /* SecondaryNavigationView.swift in Sources */, + D08B8D602540DE3B00B1EBEF /* ZoomAnimator.swift in Sources */, + D08B8D672540DEB200B1EBEF /* ZoomAnimatableView.swift in Sources */, D0C7D4DA24F7616A001EBDBB /* View+Extensions.swift in Sources */, D0C7D4D524F7616A001EBDBB /* String+Extensions.swift in Sources */, D0C7D4A224F7616A001EBDBB /* NotificationTypesPreferencesView.swift in Sources */, diff --git a/Transitions/ZoomAnimatableView.swift b/Transitions/ZoomAnimatableView.swift new file mode 100644 index 0000000..b8ff4dc --- /dev/null +++ b/Transitions/ZoomAnimatableView.swift @@ -0,0 +1,43 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import AVFoundation +import UIKit + +protocol ZoomAnimatableView { + func transitionView() -> UIView + func frame(inView view: UIView) -> CGRect +} + +extension UIImageView: ZoomAnimatableView { + func transitionView() -> UIView { + let transitionView = UIImageView(image: image) + + transitionView.contentMode = .scaleAspectFill + transitionView.clipsToBounds = true + + return transitionView + } + + func frame(inView view: UIView) -> CGRect { + guard let image = image else { return .zero } + + return AVMakeRect(aspectRatio: image.size, insideRect: view.frame) + } +} + +extension PlayerView: ZoomAnimatableView { + func transitionView() -> UIView { + let transitionView = PlayerView() + + transitionView.videoGravity = .resizeAspectFill + transitionView.player = player + + return transitionView + } + + func frame(inView view: UIView) -> CGRect { + guard let item = player?.currentItem else { return .zero } + + return AVMakeRect(aspectRatio: item.presentationSize, insideRect: view.frame) + } +} diff --git a/Transitions/ZoomAnimator.swift b/Transitions/ZoomAnimator.swift new file mode 100644 index 0000000..bf60a04 --- /dev/null +++ b/Transitions/ZoomAnimator.swift @@ -0,0 +1,128 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import UIKit + +protocol ZoomAnimatorDelegate: class { + func transitionWillStartWith(zoomAnimator: ZoomAnimator) + func transitionDidEndWith(zoomAnimator: ZoomAnimator) + func referenceView(for zoomAnimator: ZoomAnimator) -> UIView? + func referenceViewFrameInTransitioningView(for zoomAnimator: ZoomAnimator) -> CGRect? +} + +class ZoomAnimator: NSObject { + weak var fromDelegate: ZoomAnimatorDelegate? + weak var toDelegate: ZoomAnimatorDelegate? + + var transitionView: UIView? + var isPresenting = true +} + +extension ZoomAnimator: UIViewControllerAnimatedTransitioning { + func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { + isPresenting ? .defaultAnimationDuration : .shortAnimationDuration + } + + func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { + fromDelegate?.transitionWillStartWith(zoomAnimator: self) + toDelegate?.transitionWillStartWith(zoomAnimator: self) + + if isPresenting { + animateZoomInTransition(using: transitionContext) + } else { + animateZoomOutTransition(using: transitionContext) + } + } +} + +private extension ZoomAnimator { + private func animateZoomInTransition(using transitionContext: UIViewControllerContextTransitioning) { + guard + let toVC = transitionContext.viewController(forKey: .to), + let fromVC = transitionContext.viewController(forKey: .from), + let fromReferenceView = fromDelegate?.referenceView(for: self), + let toReferenceView = toDelegate?.referenceView(for: self), + let fromReferenceViewFrame = fromDelegate?.referenceViewFrameInTransitioningView(for: self) + else { return } + + toVC.view.alpha = 0 + toReferenceView.isHidden = true + transitionContext.containerView.addSubview(toVC.view) + + if transitionView == nil, let transitionView = (fromReferenceView as? ZoomAnimatableView)?.transitionView() { + transitionView.frame = fromReferenceViewFrame + self.transitionView = transitionView + transitionContext.containerView.addSubview(transitionView) + } + + fromReferenceView.isHidden = true + + let finalTransitionSize = (fromReferenceView as? ZoomAnimatableView)?.frame(inView: toVC.view) ?? .zero + + UIView.animate( + withDuration: transitionDuration(using: transitionContext), + delay: 0, + usingSpringWithDamping: 0.8, + initialSpringVelocity: 0, + options: [.transitionCrossDissolve]) { + self.transitionView?.frame = finalTransitionSize + toVC.view.alpha = 1.0 + fromVC.tabBarController?.tabBar.alpha = 0 + } completion: { _ in + self.transitionView?.removeFromSuperview() + toReferenceView.isHidden = false + fromReferenceView.isHidden = false + + self.transitionView = nil + + transitionContext.completeTransition(!transitionContext.transitionWasCancelled) + self.toDelegate?.transitionDidEndWith(zoomAnimator: self) + self.fromDelegate?.transitionDidEndWith(zoomAnimator: self) + } + } + + private func animateZoomOutTransition(using transitionContext: UIViewControllerContextTransitioning) { + let containerView = transitionContext.containerView + + guard + let toVC = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.to), + let fromVC = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.from), + let fromReferenceView = fromDelegate?.referenceView(for: self), + let fromReferenceViewFrame = fromDelegate?.referenceViewFrameInTransitioningView(for: self) + else { return } + + let toReferenceView = toDelegate?.referenceView(for: self) + let toReferenceViewFrame = toDelegate?.referenceViewFrameInTransitioningView(for: self) + + toReferenceView?.isHidden = true + + if transitionView == nil, let transitionView = (fromReferenceView as? ZoomAnimatableView)?.transitionView() { + transitionView.frame = fromReferenceViewFrame + self.transitionView = transitionView + containerView.addSubview(transitionView) + } + + containerView.insertSubview(toVC.view, belowSubview: fromVC.view) + fromReferenceView.isHidden = true + + UIView.animate( + withDuration: transitionDuration(using: transitionContext)) { + fromVC.view.alpha = 0 + + if let toReferenceViewFrame = toReferenceViewFrame { + self.transitionView?.frame = toReferenceViewFrame + } else { + self.transitionView?.alpha = 0 + } + + toVC.tabBarController?.tabBar.alpha = 1 + } completion: { _ in + self.transitionView?.removeFromSuperview() + toReferenceView?.isHidden = false + fromReferenceView.isHidden = false + + transitionContext.completeTransition(!transitionContext.transitionWasCancelled) + self.toDelegate?.transitionDidEndWith(zoomAnimator: self) + self.fromDelegate?.transitionDidEndWith(zoomAnimator: self) + } + } +} diff --git a/Transitions/ZoomDismissalInteractionController.swift b/Transitions/ZoomDismissalInteractionController.swift new file mode 100644 index 0000000..f10b34d --- /dev/null +++ b/Transitions/ZoomDismissalInteractionController.swift @@ -0,0 +1,154 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import UIKit + +class ZoomDismissalInteractionController: NSObject { + var transitionContext: UIViewControllerContextTransitioning? + var animator: UIViewControllerAnimatedTransitioning? + + var fromReferenceViewFrame: CGRect? + var toReferenceViewFrame: CGRect? + + // swiftlint:disable:next function_body_length + func didPanWith(gestureRecognizer: UIPanGestureRecognizer) { + guard let transitionContext = self.transitionContext, + let animator = self.animator as? ZoomAnimator, + let transitionView = animator.transitionView, + let fromVC = transitionContext.viewController(forKey: .from), + let toVC = transitionContext.viewController(forKey: .to), + let fromReferenceView = animator.fromDelegate?.referenceView(for: animator), + let fromReferenceViewFrame = self.fromReferenceViewFrame + else { return } + + let toReferenceView = animator.toDelegate?.referenceView(for: animator) + + fromReferenceView.isHidden = true + + let anchorPoint = CGPoint(x: fromReferenceViewFrame.midX, y: fromReferenceViewFrame.midY) + let dismissThreshold = fromReferenceViewFrame.height / 8 + let translatedPoint = gestureRecognizer.translation(in: fromReferenceView) + + let backgroundAlpha = backgroundAlphaFor(view: fromVC.view, withPanningVerticalDelta: translatedPoint.y) + let scale = scaleFor(view: fromVC.view, withPanningVerticalDelta: translatedPoint.y) + + fromVC.view.alpha = backgroundAlpha + + transitionView.transform = CGAffineTransform(scaleX: scale, y: scale) + let newCenter = CGPoint( + x: anchorPoint.x + translatedPoint.x, + y: anchorPoint.y + translatedPoint.y - transitionView.frame.height * (1 - scale) / 2.0) + transitionView.center = newCenter + + toReferenceView?.isHidden = true + + transitionContext.updateInteractiveTransition(1 - scale) + + toVC.tabBarController?.tabBar.alpha = 1 - backgroundAlpha + + if gestureRecognizer.state == .ended { + if abs(anchorPoint.y - newCenter.y) < dismissThreshold { + // cancel + UIView.animate( + withDuration: 0.5, + delay: 0, + usingSpringWithDamping: 0.9, + initialSpringVelocity: 0, + options: []) { + transitionView.frame = fromReferenceViewFrame + fromVC.view.alpha = 1.0 + toVC.tabBarController?.tabBar.alpha = 0 + } completion: { _ in + toReferenceView?.isHidden = false + fromReferenceView.isHidden = false + transitionView.removeFromSuperview() + animator.transitionView = nil + transitionContext.cancelInteractiveTransition() + transitionContext.completeTransition(!transitionContext.transitionWasCancelled) + animator.toDelegate?.transitionDidEndWith(zoomAnimator: animator) + animator.fromDelegate?.transitionDidEndWith(zoomAnimator: animator) + self.transitionContext = nil + } + + return + } + + // start animation + UIView.animate( + withDuration: .shortAnimationDuration) { + fromVC.view.alpha = 0 + + if let toReferenceViewFrame = self.toReferenceViewFrame { + transitionView.frame = toReferenceViewFrame + } else { + transitionView.alpha = 0 + } + + toVC.tabBarController?.tabBar.alpha = 1 + } completion: { _ in + transitionView.removeFromSuperview() + toReferenceView?.isHidden = false + fromReferenceView.isHidden = false + + self.transitionContext?.finishInteractiveTransition() + transitionContext.completeTransition(!transitionContext.transitionWasCancelled) + animator.toDelegate?.transitionDidEndWith(zoomAnimator: animator) + animator.fromDelegate?.transitionDidEndWith(zoomAnimator: animator) + self.transitionContext = nil + } + } + } + + func backgroundAlphaFor(view: UIView, withPanningVerticalDelta verticalDelta: CGFloat) -> CGFloat { + let startingAlpha: CGFloat = 1.0 + let finalAlpha: CGFloat = 0.0 + let totalAvailableAlpha = startingAlpha - finalAlpha + + let maximumDelta = view.bounds.height / 4.0 + let deltaAsPercentageOfMaximun = min(abs(verticalDelta) / maximumDelta, 1.0) + + return startingAlpha - (deltaAsPercentageOfMaximun * totalAvailableAlpha) + } + + func scaleFor(view: UIView, withPanningVerticalDelta verticalDelta: CGFloat) -> CGFloat { + let startingScale: CGFloat = 1.0 + let finalScale: CGFloat = 0.5 + let totalAvailableScale = startingScale - finalScale + + let maximumDelta = view.bounds.height / 2.0 + let deltaAsPercentageOfMaximun = min(abs(verticalDelta) / maximumDelta, 1.0) + + return startingScale - (deltaAsPercentageOfMaximun * totalAvailableScale) + } +} + +extension ZoomDismissalInteractionController: UIViewControllerInteractiveTransitioning { + func startInteractiveTransition(_ transitionContext: UIViewControllerContextTransitioning) { + guard let animator = animator as? ZoomAnimator else { return } + + animator.fromDelegate?.transitionWillStartWith(zoomAnimator: animator) + animator.toDelegate?.transitionWillStartWith(zoomAnimator: animator) + + self.transitionContext = transitionContext + + let containerView = transitionContext.containerView + + guard + let fromVC = transitionContext.viewController(forKey: .from), + let toVC = transitionContext.viewController(forKey: .to), + let fromReferenceViewFrame = animator.fromDelegate?.referenceViewFrameInTransitioningView(for: animator), + let fromReferenceView = animator.fromDelegate?.referenceView(for: animator) + else { return } + + self.fromReferenceViewFrame = fromReferenceViewFrame + toReferenceViewFrame = animator.toDelegate?.referenceViewFrameInTransitioningView(for: animator) + + containerView.insertSubview(toVC.view, belowSubview: fromVC.view) + + if animator.transitionView == nil, + let transitionView = (fromReferenceView as? ZoomAnimatableView)?.transitionView() { + transitionView.frame = fromReferenceViewFrame + animator.transitionView = transitionView + containerView.addSubview(transitionView) + } + } +} diff --git a/Transitions/ZoomTransitionController.swift b/Transitions/ZoomTransitionController.swift new file mode 100644 index 0000000..9b81c66 --- /dev/null +++ b/Transitions/ZoomTransitionController.swift @@ -0,0 +1,79 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import UIKit + +class ZoomTransitionController: NSObject { + var isInteractive = false + + weak var fromDelegate: ZoomAnimatorDelegate? + weak var toDelegate: ZoomAnimatorDelegate? + + private let animator = ZoomAnimator() + private let interactionController = ZoomDismissalInteractionController() + + func didPanWith(gestureRecognizer: UIPanGestureRecognizer) { + interactionController.didPanWith(gestureRecognizer: gestureRecognizer) + } +} + +extension ZoomTransitionController: UIViewControllerTransitioningDelegate { + func animationController( + forPresented presented: UIViewController, + presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? { + presentingAnimator() + } + + func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? { + dismissingAnimator() + } + + func interactionControllerForDismissal( + using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? { + interactionController(animator: animator) + } + +} + +extension ZoomTransitionController: UINavigationControllerDelegate { + func navigationController(_ navigationController: UINavigationController, + animationControllerFor operation: UINavigationController.Operation, + from fromVC: UIViewController, + to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? { + operation == .push ? presentingAnimator() : dismissingAnimator() + } + + func navigationController( + _ navigationController: UINavigationController, + interactionControllerFor animationController: UIViewControllerAnimatedTransitioning) + -> UIViewControllerInteractiveTransitioning? { + interactionController(animator: animator) + } +} + +private extension ZoomTransitionController { + private func presentingAnimator() -> UIViewControllerAnimatedTransitioning { + animator.isPresenting = true + animator.fromDelegate = fromDelegate + animator.toDelegate = toDelegate + + return animator + } + + private func dismissingAnimator() -> UIViewControllerAnimatedTransitioning { + animator.isPresenting = false + let tmp = fromDelegate + animator.fromDelegate = toDelegate + animator.toDelegate = tmp + + return animator + } + + private func interactionController( + animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? { + guard isInteractive else { return nil } + + interactionController.animator = animator + + return interactionController + } +} diff --git a/View Controllers/ImageNavigationController.swift b/View Controllers/ImageNavigationController.swift index 31af1e7..7e0f2c6 100644 --- a/View Controllers/ImageNavigationController.swift +++ b/View Controllers/ImageNavigationController.swift @@ -1,8 +1,11 @@ // Copyright © 2020 Metabolist. All rights reserved. +import AVFoundation import UIKit class ImageNavigationController: UINavigationController { + let transitionController = ZoomTransitionController() + private let imagePageViewController: ImagePageViewController init(imagePageViewController: ImagePageViewController) { @@ -21,5 +24,91 @@ class ImageNavigationController: UINavigationController { hidesBarsOnTap = true modalPresentationStyle = .fullScreen + + let panGestureRecognizer = UIPanGestureRecognizer( + target: self, + action: #selector(didPanWith(gestureRecognizer:))) + + panGestureRecognizer.delegate = self + view.addGestureRecognizer(panGestureRecognizer) + + transitioningDelegate = transitionController + transitionController.toDelegate = self + } +} + +extension ImageNavigationController { + var currentViewController: ImageViewController? { + imagePageViewController.viewControllers?.first as? ImageViewController + } + + @objc func didPanWith(gestureRecognizer: UIPanGestureRecognizer) { + guard let currentViewController = currentViewController else { return } + + switch gestureRecognizer.state { + case .began: + currentViewController.scrollView.isScrollEnabled = false + transitionController.isInteractive = true + + presentingViewController?.dismiss(animated: true) + case .ended: + if transitionController.isInteractive { + currentViewController.scrollView.isScrollEnabled = true + transitionController.isInteractive = false + transitionController.didPanWith(gestureRecognizer: gestureRecognizer) + } + default: + if transitionController.isInteractive { + transitionController.didPanWith(gestureRecognizer: gestureRecognizer) + } + } + } +} + +extension ImageNavigationController: ZoomAnimatorDelegate { + func transitionWillStartWith(zoomAnimator: ZoomAnimator) { + + } + + func transitionDidEndWith(zoomAnimator: ZoomAnimator) { + + } + + func referenceView(for zoomAnimator: ZoomAnimator) -> UIView? { + if currentViewController?.playerView.player != nil { + return currentViewController?.playerView + } else { + return currentViewController?.imageView + } + } + + func referenceViewFrameInTransitioningView(for zoomAnimator: ZoomAnimator) -> CGRect? { + guard let currentViewController = currentViewController else { return .zero } + + let rect: CGRect + + if let image = currentViewController.imageView.image { + rect = AVMakeRect(aspectRatio: image.size, insideRect: currentViewController.imageView.frame) + } else if let item = currentViewController.playerView.player?.currentItem { + rect = AVMakeRect(aspectRatio: item.presentationSize, insideRect: currentViewController.playerView.frame) + } else { + return .zero + } + + return currentViewController.scrollView.convert(rect, to: currentViewController.view) + } +} + +extension ImageNavigationController: UIGestureRecognizerDelegate { + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { + if + let currentViewController = currentViewController, + otherGestureRecognizer == currentViewController.scrollView.panGestureRecognizer, + currentViewController.scrollView.contentOffset.y == 0 { + return true + } + + return false } } diff --git a/View Controllers/ImageViewController.swift b/View Controllers/ImageViewController.swift index 0f6b548..05999c4 100644 --- a/View Controllers/ImageViewController.swift +++ b/View Controllers/ImageViewController.swift @@ -5,11 +5,12 @@ import UIKit import ViewModels class ImageViewController: UIViewController { + let scrollView = UIScrollView() + let imageView = AnimatedImageView() + let playerView = PlayerView() + private let viewModel: AttachmentViewModel - private let scrollView = UIScrollView() private let contentView = UIView() - private let imageView = AnimatedImageView() - private let playerView = PlayerView() private let descriptionBackgroundView = UIVisualEffectView(effect: UIBlurEffect(style: .systemChromeMaterial)) private let descriptionTextView = UITextView() @@ -93,6 +94,7 @@ class ImageViewController: UIViewController { switch viewModel.attachment.type { case .image: + imageView.tag = viewModel.tag playerView.isHidden = true imageView.isHidden = false imageView.kf.indicatorType = .activity @@ -111,6 +113,7 @@ class ImageViewController: UIViewController { options: [.keepCurrentImageWhileLoading]) }) case .gifv: + playerView.tag = viewModel.tag playerView.isHidden = false imageView.isHidden = true let player = PlayerCache.shared.player(url: viewModel.attachment.url) diff --git a/View Controllers/TableViewController.swift b/View Controllers/TableViewController.swift index 65426ea..11f4a92 100644 --- a/View Controllers/TableViewController.swift +++ b/View Controllers/TableViewController.swift @@ -13,6 +13,7 @@ class TableViewController: UITableViewController { private let webfingerIndicatorView = WebfingerIndicatorView() private var cancellables = Set() private var cellHeightCaches = [CGFloat: [CollectionItem: CGFloat]]() + private var transitionViewTag = -1 private lazy var dataSource: TableViewDataSource = { .init(tableView: tableView, viewModelProvider: viewModel.viewModel(indexPath:)) @@ -181,10 +182,36 @@ extension TableViewController: AVPlayerViewControllerDelegate { } } -private extension TableViewController { - static let autoplayViews = [PlayerView](repeating: .init(), count: 4) - static var visibleVideoURLs = Set() +extension TableViewController: ZoomAnimatorDelegate { + func transitionWillStartWith(zoomAnimator: ZoomAnimator) { + view.layoutIfNeeded() + guard let imageViewController = (presentedViewController as? ImageNavigationController)?.currentViewController + else { return } + + if imageViewController.playerView.tag != 0 { + transitionViewTag = imageViewController.playerView.tag + } else if imageViewController.imageView.tag != 0 { + transitionViewTag = imageViewController.imageView.tag + } + } + + func transitionDidEndWith(zoomAnimator: ZoomAnimator) { + + } + + func referenceView(for zoomAnimator: ZoomAnimator) -> UIView? { + tableView.visibleCells.compactMap { $0.viewWithTag(transitionViewTag) }.first + } + + func referenceViewFrameInTransitioningView(for zoomAnimator: ZoomAnimator) -> CGRect? { + guard let referenceView = referenceView(for: zoomAnimator) else { return nil } + + return tabBarController?.view.convert(referenceView.frame, from: referenceView.superview) + } +} + +private extension TableViewController { var visibleLoadMoreViews: [LoadMoreView] { tableView.visibleCells.compactMap { $0.contentView as? LoadMoreView } } @@ -315,6 +342,9 @@ private extension TableViewController { statusViewModel: statusViewModel) let imageNavigationController = ImageNavigationController(imagePageViewController: imagePageViewController) + imageNavigationController.transitionController.fromDelegate = self + transitionViewTag = attachmentViewModel.tag + present(imageNavigationController, animated: true) case .unknown: break diff --git a/ViewModels/Sources/ViewModels/AttachmentViewModel.swift b/ViewModels/Sources/ViewModels/AttachmentViewModel.swift index 3224883..55876a6 100644 --- a/ViewModels/Sources/ViewModels/AttachmentViewModel.swift +++ b/ViewModels/Sources/ViewModels/AttachmentViewModel.swift @@ -6,12 +6,19 @@ import Mastodon public struct AttachmentViewModel { public let attachment: Attachment - init(attachment: Attachment) { + private let status: Status + + init(attachment: Attachment, status: Status) { self.attachment = attachment + self.status = status } } public extension AttachmentViewModel { + var tag: Int { + attachment.id.appending(status.id).hashValue + } + var aspectRatio: Double? { if let info = attachment.meta?.original, diff --git a/ViewModels/Sources/ViewModels/StatusViewModel.swift b/ViewModels/Sources/ViewModels/StatusViewModel.swift index a90710d..1c3829d 100644 --- a/ViewModels/Sources/ViewModels/StatusViewModel.swift +++ b/ViewModels/Sources/ViewModels/StatusViewModel.swift @@ -40,7 +40,7 @@ public struct StatusViewModel: CollectionItemViewModel { : statusService.status.account.displayName rebloggedByDisplayNameEmoji = statusService.status.account.emojis attachmentViewModels = statusService.status.displayStatus.mediaAttachments - .map(AttachmentViewModel.init(attachment:)) + .map { AttachmentViewModel(attachment: $0, status: statusService.status) } pollOptionTitles = statusService.status.displayStatus.poll?.options.map { $0.title } ?? [] pollEmoji = statusService.status.displayStatus.poll?.emojis ?? [] events = eventsSubject.eraseToAnyPublisher() diff --git a/Views/Status/StatusAttachmentView.swift b/Views/Status/StatusAttachmentView.swift index 10fbb8f..4202b2b 100644 --- a/Views/Status/StatusAttachmentView.swift +++ b/Views/Status/StatusAttachmentView.swift @@ -14,8 +14,12 @@ final class StatusAttachmentView: UIView { didSet { if playing { play() + imageView.tag = 0 + playerView.tag = viewModel.tag } else { stop() + imageView.tag = viewModel.tag + playerView.tag = 0 } } } @@ -79,6 +83,7 @@ private extension StatusAttachmentView { imageView.translatesAutoresizingMaskIntoConstraints = false imageView.contentMode = .scaleAspectFill imageView.clipsToBounds = true + imageView.tag = viewModel.tag let blurEffect = UIBlurEffect(style: .systemUltraThinMaterial) let playView = UIVisualEffectView(effect: blurEffect)