Zoom transition

This commit is contained in:
Justin Mazzocchi 2020-10-21 22:05:50 -07:00
parent 9bb7ae0609
commit 8de227d780
No known key found for this signature in database
GPG key ID: E223E6937AAFB01C
11 changed files with 570 additions and 8 deletions

View file

@ -22,6 +22,10 @@
D08B8D3D253F929E00B1EBEF /* ImageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08B8D3C253F929E00B1EBEF /* ImageViewController.swift */; }; D08B8D3D253F929E00B1EBEF /* ImageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08B8D3C253F929E00B1EBEF /* ImageViewController.swift */; };
D08B8D42253F92B600B1EBEF /* ImagePageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08B8D41253F92B600B1EBEF /* ImagePageViewController.swift */; }; D08B8D42253F92B600B1EBEF /* ImagePageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08B8D41253F92B600B1EBEF /* ImagePageViewController.swift */; };
D08B8D4A253FC36500B1EBEF /* ImageNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08B8D49253FC36500B1EBEF /* ImageNavigationController.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 */; }; D0A1F4F7252E7D4B004435BF /* TableViewDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A1F4F6252E7D4B004435BF /* TableViewDataSource.swift */; };
D0A3C2F725390A9700739F88 /* AppPreferences+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A3C2F625390A9700739F88 /* AppPreferences+Extensions.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 */; }; 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 = "<group>"; }; D08B8D3C253F929E00B1EBEF /* ImageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageViewController.swift; sourceTree = "<group>"; };
D08B8D41253F92B600B1EBEF /* ImagePageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePageViewController.swift; sourceTree = "<group>"; }; D08B8D41253F92B600B1EBEF /* ImagePageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePageViewController.swift; sourceTree = "<group>"; };
D08B8D49253FC36500B1EBEF /* ImageNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageNavigationController.swift; sourceTree = "<group>"; }; D08B8D49253FC36500B1EBEF /* ImageNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageNavigationController.swift; sourceTree = "<group>"; };
D08B8D5D2540DE3A00B1EBEF /* ZoomAnimator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ZoomAnimator.swift; sourceTree = "<group>"; };
D08B8D5E2540DE3A00B1EBEF /* ZoomDismissalInteractionController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ZoomDismissalInteractionController.swift; sourceTree = "<group>"; };
D08B8D5F2540DE3A00B1EBEF /* ZoomTransitionController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ZoomTransitionController.swift; sourceTree = "<group>"; };
D08B8D662540DEB200B1EBEF /* ZoomAnimatableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ZoomAnimatableView.swift; sourceTree = "<group>"; };
D0A1F4F6252E7D4B004435BF /* TableViewDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewDataSource.swift; sourceTree = "<group>"; }; D0A1F4F6252E7D4B004435BF /* TableViewDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewDataSource.swift; sourceTree = "<group>"; };
D0A3C2F625390A9700739F88 /* AppPreferences+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppPreferences+Extensions.swift"; sourceTree = "<group>"; }; D0A3C2F625390A9700739F88 /* AppPreferences+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppPreferences+Extensions.swift"; sourceTree = "<group>"; };
D0AD03552505814D0085A466 /* Base16 */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Base16; sourceTree = "<group>"; }; D0AD03552505814D0085A466 /* Base16 */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Base16; sourceTree = "<group>"; };
@ -234,6 +242,7 @@
D0C7D41D24F76169001EBDBB /* Supporting Files */, D0C7D41D24F76169001EBDBB /* Supporting Files */,
D0C7D45324F76169001EBDBB /* System */, D0C7D45324F76169001EBDBB /* System */,
D0666A2224C677B400F3F04B /* Tests */, D0666A2224C677B400F3F04B /* Tests */,
D08B8D5C2540DDFC00B1EBEF /* Transitions */,
D0C7D43024F76169001EBDBB /* View Controllers */, D0C7D43024F76169001EBDBB /* View Controllers */,
D0E2C1CF24FD8BA400854680 /* ViewModels */, D0E2C1CF24FD8BA400854680 /* ViewModels */,
D0C7D42024F76169001EBDBB /* Views */, D0C7D42024F76169001EBDBB /* Views */,
@ -278,6 +287,17 @@
name = Frameworks; name = Frameworks;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
D08B8D5C2540DDFC00B1EBEF /* Transitions */ = {
isa = PBXGroup;
children = (
D08B8D662540DEB200B1EBEF /* ZoomAnimatableView.swift */,
D08B8D5D2540DE3A00B1EBEF /* ZoomAnimator.swift */,
D08B8D5E2540DE3A00B1EBEF /* ZoomDismissalInteractionController.swift */,
D08B8D5F2540DE3A00B1EBEF /* ZoomTransitionController.swift */,
);
path = Transitions;
sourceTree = "<group>";
};
D0A1F4F5252E7D2A004435BF /* Data Sources */ = { D0A1F4F5252E7D2A004435BF /* Data Sources */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -569,12 +589,14 @@
D0C7D49C24F7616A001EBDBB /* RootView.swift in Sources */, D0C7D49C24F7616A001EBDBB /* RootView.swift in Sources */,
D0F0B126251A90F400942152 /* AccountListCell.swift in Sources */, D0F0B126251A90F400942152 /* AccountListCell.swift in Sources */,
D0B32F50250B373600311912 /* RegistrationView.swift in Sources */, D0B32F50250B373600311912 /* RegistrationView.swift in Sources */,
D08B8D612540DE3B00B1EBEF /* ZoomDismissalInteractionController.swift in Sources */,
D0E569E0252931B100FA1D72 /* LoadMoreContentConfiguration.swift in Sources */, D0E569E0252931B100FA1D72 /* LoadMoreContentConfiguration.swift in Sources */,
D0FE1C9825368A9D003EF1EB /* PlayerCache.swift in Sources */, D0FE1C9825368A9D003EF1EB /* PlayerCache.swift in Sources */,
D0F0B136251AA12700942152 /* CollectionItem+Extensions.swift in Sources */, D0F0B136251AA12700942152 /* CollectionItem+Extensions.swift in Sources */,
D0625E5D250F0B5C00502611 /* StatusContentConfiguration.swift in Sources */, D0625E5D250F0B5C00502611 /* StatusContentConfiguration.swift in Sources */,
D0BEB1F324F8EE8C001B0F04 /* StatusAttachmentView.swift in Sources */, D0BEB1F324F8EE8C001B0F04 /* StatusAttachmentView.swift in Sources */,
D0C7D49A24F7616A001EBDBB /* TableView.swift in Sources */, D0C7D49A24F7616A001EBDBB /* TableView.swift in Sources */,
D08B8D622540DE3B00B1EBEF /* ZoomTransitionController.swift in Sources */,
D0F0B12E251A97E400942152 /* TableViewController.swift in Sources */, D0F0B12E251A97E400942152 /* TableViewController.swift in Sources */,
D0FE1C8F253686F9003EF1EB /* PlayerView.swift in Sources */, D0FE1C8F253686F9003EF1EB /* PlayerView.swift in Sources */,
D0F0B113251A86A000942152 /* AccountContentConfiguration.swift in Sources */, D0F0B113251A86A000942152 /* AccountContentConfiguration.swift in Sources */,
@ -586,6 +608,8 @@
D0C7D49D24F7616A001EBDBB /* PostingReadingPreferencesView.swift in Sources */, D0C7D49D24F7616A001EBDBB /* PostingReadingPreferencesView.swift in Sources */,
D0B5FE9B251583DB00478838 /* ProfileCollection+Extensions.swift in Sources */, D0B5FE9B251583DB00478838 /* ProfileCollection+Extensions.swift in Sources */,
D0C7D49E24F7616A001EBDBB /* SecondaryNavigationView.swift in Sources */, D0C7D49E24F7616A001EBDBB /* SecondaryNavigationView.swift in Sources */,
D08B8D602540DE3B00B1EBEF /* ZoomAnimator.swift in Sources */,
D08B8D672540DEB200B1EBEF /* ZoomAnimatableView.swift in Sources */,
D0C7D4DA24F7616A001EBDBB /* View+Extensions.swift in Sources */, D0C7D4DA24F7616A001EBDBB /* View+Extensions.swift in Sources */,
D0C7D4D524F7616A001EBDBB /* String+Extensions.swift in Sources */, D0C7D4D524F7616A001EBDBB /* String+Extensions.swift in Sources */,
D0C7D4A224F7616A001EBDBB /* NotificationTypesPreferencesView.swift in Sources */, D0C7D4A224F7616A001EBDBB /* NotificationTypesPreferencesView.swift in Sources */,

View file

@ -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)
}
}

View file

@ -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)
}
}
}

View file

@ -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)
}
}
}

View file

@ -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
}
}

View file

@ -1,8 +1,11 @@
// Copyright © 2020 Metabolist. All rights reserved. // Copyright © 2020 Metabolist. All rights reserved.
import AVFoundation
import UIKit import UIKit
class ImageNavigationController: UINavigationController { class ImageNavigationController: UINavigationController {
let transitionController = ZoomTransitionController()
private let imagePageViewController: ImagePageViewController private let imagePageViewController: ImagePageViewController
init(imagePageViewController: ImagePageViewController) { init(imagePageViewController: ImagePageViewController) {
@ -21,5 +24,91 @@ class ImageNavigationController: UINavigationController {
hidesBarsOnTap = true hidesBarsOnTap = true
modalPresentationStyle = .fullScreen 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
} }
} }

View file

@ -5,11 +5,12 @@ import UIKit
import ViewModels import ViewModels
class ImageViewController: UIViewController { class ImageViewController: UIViewController {
let scrollView = UIScrollView()
let imageView = AnimatedImageView()
let playerView = PlayerView()
private let viewModel: AttachmentViewModel private let viewModel: AttachmentViewModel
private let scrollView = UIScrollView()
private let contentView = UIView() private let contentView = UIView()
private let imageView = AnimatedImageView()
private let playerView = PlayerView()
private let descriptionBackgroundView = UIVisualEffectView(effect: UIBlurEffect(style: .systemChromeMaterial)) private let descriptionBackgroundView = UIVisualEffectView(effect: UIBlurEffect(style: .systemChromeMaterial))
private let descriptionTextView = UITextView() private let descriptionTextView = UITextView()
@ -93,6 +94,7 @@ class ImageViewController: UIViewController {
switch viewModel.attachment.type { switch viewModel.attachment.type {
case .image: case .image:
imageView.tag = viewModel.tag
playerView.isHidden = true playerView.isHidden = true
imageView.isHidden = false imageView.isHidden = false
imageView.kf.indicatorType = .activity imageView.kf.indicatorType = .activity
@ -111,6 +113,7 @@ class ImageViewController: UIViewController {
options: [.keepCurrentImageWhileLoading]) options: [.keepCurrentImageWhileLoading])
}) })
case .gifv: case .gifv:
playerView.tag = viewModel.tag
playerView.isHidden = false playerView.isHidden = false
imageView.isHidden = true imageView.isHidden = true
let player = PlayerCache.shared.player(url: viewModel.attachment.url) let player = PlayerCache.shared.player(url: viewModel.attachment.url)

View file

@ -13,6 +13,7 @@ class TableViewController: UITableViewController {
private let webfingerIndicatorView = WebfingerIndicatorView() private let webfingerIndicatorView = WebfingerIndicatorView()
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
private var cellHeightCaches = [CGFloat: [CollectionItem: CGFloat]]() private var cellHeightCaches = [CGFloat: [CollectionItem: CGFloat]]()
private var transitionViewTag = -1
private lazy var dataSource: TableViewDataSource = { private lazy var dataSource: TableViewDataSource = {
.init(tableView: tableView, viewModelProvider: viewModel.viewModel(indexPath:)) .init(tableView: tableView, viewModelProvider: viewModel.viewModel(indexPath:))
@ -181,10 +182,36 @@ extension TableViewController: AVPlayerViewControllerDelegate {
} }
} }
private extension TableViewController { extension TableViewController: ZoomAnimatorDelegate {
static let autoplayViews = [PlayerView](repeating: .init(), count: 4) func transitionWillStartWith(zoomAnimator: ZoomAnimator) {
static var visibleVideoURLs = Set<URL>() 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] { var visibleLoadMoreViews: [LoadMoreView] {
tableView.visibleCells.compactMap { $0.contentView as? LoadMoreView } tableView.visibleCells.compactMap { $0.contentView as? LoadMoreView }
} }
@ -315,6 +342,9 @@ private extension TableViewController {
statusViewModel: statusViewModel) statusViewModel: statusViewModel)
let imageNavigationController = ImageNavigationController(imagePageViewController: imagePageViewController) let imageNavigationController = ImageNavigationController(imagePageViewController: imagePageViewController)
imageNavigationController.transitionController.fromDelegate = self
transitionViewTag = attachmentViewModel.tag
present(imageNavigationController, animated: true) present(imageNavigationController, animated: true)
case .unknown: case .unknown:
break break

View file

@ -6,12 +6,19 @@ import Mastodon
public struct AttachmentViewModel { public struct AttachmentViewModel {
public let attachment: Attachment public let attachment: Attachment
init(attachment: Attachment) { private let status: Status
init(attachment: Attachment, status: Status) {
self.attachment = attachment self.attachment = attachment
self.status = status
} }
} }
public extension AttachmentViewModel { public extension AttachmentViewModel {
var tag: Int {
attachment.id.appending(status.id).hashValue
}
var aspectRatio: Double? { var aspectRatio: Double? {
if if
let info = attachment.meta?.original, let info = attachment.meta?.original,

View file

@ -40,7 +40,7 @@ public struct StatusViewModel: CollectionItemViewModel {
: statusService.status.account.displayName : statusService.status.account.displayName
rebloggedByDisplayNameEmoji = statusService.status.account.emojis rebloggedByDisplayNameEmoji = statusService.status.account.emojis
attachmentViewModels = statusService.status.displayStatus.mediaAttachments 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 } ?? [] pollOptionTitles = statusService.status.displayStatus.poll?.options.map { $0.title } ?? []
pollEmoji = statusService.status.displayStatus.poll?.emojis ?? [] pollEmoji = statusService.status.displayStatus.poll?.emojis ?? []
events = eventsSubject.eraseToAnyPublisher() events = eventsSubject.eraseToAnyPublisher()

View file

@ -14,8 +14,12 @@ final class StatusAttachmentView: UIView {
didSet { didSet {
if playing { if playing {
play() play()
imageView.tag = 0
playerView.tag = viewModel.tag
} else { } else {
stop() stop()
imageView.tag = viewModel.tag
playerView.tag = 0
} }
} }
} }
@ -79,6 +83,7 @@ private extension StatusAttachmentView {
imageView.translatesAutoresizingMaskIntoConstraints = false imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.contentMode = .scaleAspectFill imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true imageView.clipsToBounds = true
imageView.tag = viewModel.tag
let blurEffect = UIBlurEffect(style: .systemUltraThinMaterial) let blurEffect = UIBlurEffect(style: .systemUltraThinMaterial)
let playView = UIVisualEffectView(effect: blurEffect) let playView = UIVisualEffectView(effect: blurEffect)