Skip to content

Instantly share code, notes, and snippets.

@levantAJ
Last active January 3, 2020 07:23
Show Gist options
  • Save levantAJ/fa9c133de16efafb1d4da05b63fa7599 to your computer and use it in GitHub Desktop.
Save levantAJ/fa9c133de16efafb1d4da05b63fa7599 to your computer and use it in GitHub Desktop.
Photo Preview
//
// PhotoPreviewViewController.swift
// ShopBack
//
// Created by Tai Le on 8/31/19.
// Copyright © 2019 levantAJ. All rights reserved.
//
import UIKit
import SDWebImage
final class PhotoPreviewViewController: UIViewController {
let imageURLs: [URL]
let startAtIndex: Int
let interPageSpacing: CGFloat
var dismissOnTouch = true
var sourceImageView: UIImageView?
var animationDuration: TimeInterval = 0.25
var hideNavigationBarWhilePresenting = false
private lazy var pageVC: UIPageViewController = {
return UIPageViewController(transitionStyle: .scroll,
navigationOrientation: .horizontal,
options: [.interPageSpacing: interPageSpacing])
}()
private lazy var dismissInteractor = DismissInteractor(animationDuration: animationDuration)
private var currentIndex: Int
private var dismissingImageView: UIImageView?
override var prefersStatusBarHidden: Bool {
return true
}
init(imageURLs: [URL],
startAtIndex: Int = 0,
interPageSpacing: CGFloat = 20) {
self.imageURLs = imageURLs
self.startAtIndex = startAtIndex
self.currentIndex = startAtIndex
self.interPageSpacing = interPageSpacing
super.init(nibName: nil, bundle: nil)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
setupViews()
}
}
// MARK: - UIViewControllerTransitioningDelegate
extension PhotoPreviewViewController: UIViewControllerTransitioningDelegate {
func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
return PresentAnimator(animationDuration: animationDuration,
sourceImageView: sourceImageView,
hideNavigationBarWhilePresenting: hideNavigationBarWhilePresenting)
}
func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
sourceImageView?.isHidden = currentIndex != startAtIndex
return DismissAnimator(animationDuration: animationDuration,
sourceImageView: sourceImageView == nil ? nil : dismissingImageView,
targetImageView: currentIndex == startAtIndex ? sourceImageView : nil)
}
func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
sourceImageView?.isHidden = currentIndex == startAtIndex
dismissInteractor.targetImageView = currentIndex == startAtIndex ? sourceImageView : nil
return dismissInteractor.hasStarted ? dismissInteractor : nil
}
}
// MARK: - UIPageViewControllerDataSource
extension PhotoPreviewViewController: UIPageViewControllerDataSource {
func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
guard let vc = viewController as? PhotoPreviewContentViewController,
let url = imageURLs[safe: vc.index - 1] else { return nil }
return contentVC(imageURL: url, index: vc.index - 1)
}
func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
guard let vc = viewController as? PhotoPreviewContentViewController,
let url = imageURLs[safe: vc.index + 1] else { return nil }
return contentVC(imageURL: url, index: vc.index + 1)
}
}
// MARK: - UIPageViewControllerDelegate
extension PhotoPreviewViewController: UIPageViewControllerDelegate {
func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
guard completed,
let vc = pageViewController.viewControllers?.last as? PhotoPreviewContentViewController else { return }
currentIndex = vc.index
}
}
// MARK: - Privates
extension PhotoPreviewViewController {
private func setupViews() {
view.backgroundColor = .black
transitioningDelegate = self
pageVC.dataSource = self
pageVC.delegate = self
pageVC.willMove(toParent: self)
pageVC.view.frame = view.bounds
if let url = imageURLs[safe: startAtIndex] {
let vc = contentVC(imageURL: url, index: startAtIndex)
vc.placeholderImage = sourceImageView?.image
pageVC.setViewControllers([vc],
direction: .forward,
animated: true,
completion: nil)
}
addChild(pageVC)
view.addSubview(pageVC.view)
pageVC.didMove(toParent: self)
}
private func contentVC(imageURL: URL, index: Int) -> PhotoPreviewContentViewController {
let vc = PhotoPreviewContentViewController(imageURL: imageURL, index: index)
vc.dismissInteractor = dismissInteractor
vc.onDismiss = { [weak self] dismissBy, imageView in
self?.dismissingImageView = imageView
switch dismissBy {
case .touch:
guard self?.dismissOnTouch == true else { return }
self?.dismiss(animated: true, completion: nil)
case .drag:
self?.dismiss(animated: true, completion: nil)
}
}
return vc
}
}
// MARK: - PresentAnimator
private final class PresentAnimator: NSObject, UIViewControllerAnimatedTransitioning {
let animationDuration: TimeInterval
let sourceImageView: UIImageView?
let hideNavigationBarWhilePresenting: Bool
init(animationDuration: TimeInterval,
sourceImageView: UIImageView?,
hideNavigationBarWhilePresenting: Bool) {
self.animationDuration = animationDuration
self.sourceImageView = sourceImageView
self.hideNavigationBarWhilePresenting = hideNavigationBarWhilePresenting
}
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return animationDuration
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
guard let toView = transitionContext.toView(snapshotted: true) else {
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
return
}
let containerView = transitionContext.containerView
if hideNavigationBarWhilePresenting {
(transitionContext.viewController(forKey: .from) as? UINavigationController)?.setNavigationBarHidden(true, animated: true)
}
if let sourceImageView = sourceImageView,
let image = sourceImageView.image {
toView.isHidden = true
sourceImageView.isHidden = true
containerView.addSubview(toView)
let backgroundView = UIView(frame: toView.frame)
backgroundView.backgroundColor = .black
backgroundView.alpha = 0.0
containerView.addSubview(backgroundView)
let windowFrame = sourceImageView.superview?.convert(sourceImageView.frame, to: nil)
let animatingImageView = UIImageView(frame: windowFrame ?? sourceImageView.frame)
animatingImageView.image = sourceImageView.image
animatingImageView.contentMode = sourceImageView.contentMode
containerView.addSubview(animatingImageView)
let targetImageRect = toView.rect(aspectRatio: image.size)
UIView.animate(withDuration: animationDuration,
delay: 0.0,
options: .curveLinear,
animations: {
backgroundView.alpha = 1.0
animatingImageView.frame = targetImageRect
}, completion: { _ in
toView.isHidden = false
sourceImageView.isHidden = false
backgroundView.removeFromSuperview()
animatingImageView.removeFromSuperview()
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
})
} else {
let backgroundView = UIView(frame: toView.frame)
backgroundView.backgroundColor = .black
backgroundView.alpha = 0.0
containerView.addSubview(backgroundView)
toView.alpha = 0.0
containerView.addSubview(toView)
UIView.animate(withDuration: animationDuration,
delay: 0.0,
options: .curveEaseIn,
animations: {
toView.alpha = 1.0
backgroundView.alpha = 1.0
}, completion: { _ in
backgroundView.removeFromSuperview()
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
})
}
}
}
// MARK: - DismissAnimator
private final class DismissAnimator: NSObject, UIViewControllerAnimatedTransitioning {
let animationDuration: TimeInterval
let sourceImageView: UIImageView?
let targetImageView: UIImageView?
init(animationDuration: TimeInterval,
sourceImageView: UIImageView?,
targetImageView: UIImageView?) {
self.animationDuration = animationDuration
self.sourceImageView = sourceImageView
self.targetImageView = targetImageView
}
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return animationDuration
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
guard let fromView = transitionContext.fromView(snapshotted: true),
let toView = transitionContext.toView(snapshotted: true) else {
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
return
}
let containerView = transitionContext.containerView
containerView.insertSubview(toView, belowSubview: fromView)
if let sourceImageView = sourceImageView,
let image = sourceImageView.image {
if let targetImageView = targetImageView {
targetImageView.isHidden = true
let sourceFrame = sourceImageView.rect(aspectRatio: image.size)
let animatingImageView = UIImageView(frame: sourceFrame)
animatingImageView.clipsToBounds = true
animatingImageView.image = sourceImageView.image
animatingImageView.contentMode = targetImageView.contentMode
containerView.addSubview(animatingImageView)
let windowFrame = targetImageView.superview?.convert(targetImageView.frame, to: nil)
let targetFrame = windowFrame ?? targetImageView.frame
UIView.animate(withDuration: animationDuration,
delay: 0.0,
options: .curveLinear,
animations: {
animatingImageView.frame = targetFrame
fromView.alpha = 0.0
}, completion: { _ in
targetImageView.isHidden = false
animatingImageView.removeFromSuperview()
toView.removeFromSuperview()
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
})
} else {
let targetFrame = fromView.frame.offsetBy(dx: 0, dy: fromView.frame.height)
UIView.animate(withDuration: animationDuration,
delay: 0.0,
options: .curveEaseOut,
animations: {
fromView.frame = targetFrame
fromView.alpha = 0.0
}, completion: { _ in
toView.removeFromSuperview()
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
})
}
} else {
UIView.animate(withDuration: animationDuration,
delay: 0.0,
options: .curveEaseOut,
animations: {
fromView.alpha = 0.0
}, completion: { _ in
toView.removeFromSuperview()
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
})
}
}
}
private extension UIView {
//This is get image size in scale aspect fit image view.
func rect(aspectRatio size: CGSize) -> CGRect {
var aspectFitSize = CGSize(width: frame.size.width,
height: frame.size.height)
let newWidth = frame.size.width / size.width
let newHeight = frame.size.height / size.height
if newHeight < newWidth {
aspectFitSize.width = newHeight * size.width
} else if newWidth < newHeight {
aspectFitSize.height = newWidth * size.height
}
let origin = CGPoint(x: frame.size.width / 2 - aspectFitSize.width / 2,
y: frame.size.height / 2 - aspectFitSize.height / 2)
return CGRect(origin: origin, size: aspectFitSize)
}
}
// MARK: - PhotoPreviewContentViewController
final class PhotoPreviewContentViewController: UIViewController {
let imageURL: URL
let index: Int
var onDismiss: ((_ by: DismissBy, _ source: UIImageView) -> Void)?
var dismissInteractor: DismissInteractor?
var placeholderImage: UIImage?
lazy var scrollView: UIScrollView = {
let scrollView = UIScrollView(frame: view.bounds)
scrollView.maximumZoomScale = 4.0
scrollView.contentSize = view.bounds.size
scrollView.showsVerticalScrollIndicator = false
scrollView.showsHorizontalScrollIndicator = false
scrollView.delegate = self
if #available(iOS 11, *) {
scrollView.contentInsetAdjustmentBehavior = .never
}
return scrollView
}()
lazy var imageView: UIImageView = {
let imageView = UIImageView(frame: view.bounds)
imageView.contentMode = .scaleAspectFit
imageView.isUserInteractionEnabled = true
imageView.clipsToBounds = true
imageView.sd_imageTransition = .fade
return imageView
}()
lazy var progressIndicatorView: CircularLoaderView = {
let progressIndicatorView = CircularLoaderView(frame: .zero)
progressIndicatorView.isHidden = true
return progressIndicatorView
}()
init(imageURL: URL, index: Int) {
self.imageURL = imageURL
self.index = index
super.init(nibName: nil, bundle: nil)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
setupViews()
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
scrollView.zoomScale = 1
}
enum DismissBy {
case touch
case drag
}
}
// MARK: - UIScrollViewDelegate
extension PhotoPreviewContentViewController: UIScrollViewDelegate {
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
return imageView
}
}
// MARK: - UIGestureRecognizerDelegate
extension PhotoPreviewContentViewController: UIGestureRecognizerDelegate {
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
if let panGestureRecognizer = gestureRecognizer as? UIPanGestureRecognizer,
scrollView.zoomScale == 1 {
return isVerticalGesture(panGestureRecognizer)
}
return false
}
}
// MARK: - Privates
extension PhotoPreviewContentViewController {
private var cachedImage: UIImage? {
let key = SDWebImageManager.shared().cacheKey(for: imageURL)
return SDWebImageManager.shared().imageCache?.imageFromCache(forKey: key)
}
private func setupViews() {
view.backgroundColor = .clear
view.addSubview(progressIndicatorView)
progressIndicatorView.center = view.center
view.addSubview(scrollView)
scrollView.frame = view.bounds
scrollView.addSubview(imageView)
imageView.sd_setImage(with: imageURL, placeholderImage: placeholderImage, options: .highPriority, progress: { [weak self] receivedSize, expectedSize, _ in
DispatchQueue.main.async { [weak self] in
self?.progressIndicatorView.isHidden = false
self?.progressIndicatorView.progress = CGFloat(receivedSize) / CGFloat(expectedSize)
}
}, completed: { [weak self] _, _, _, _ in
DispatchQueue.main.async { [weak self] in
self?.progressIndicatorView.isHidden = true
}
})
let dismissPanGesture = UIPanGestureRecognizer(target: self, action: #selector(handle(dismissPanGesture:)))
dismissPanGesture.delegate = self
imageView.addGestureRecognizer(dismissPanGesture)
let singleTapGesture = UITapGestureRecognizer(target: self, action: #selector(handle(singleTapGesture:)))
scrollView.addGestureRecognizer(singleTapGesture)
let doubleTapGesture = UITapGestureRecognizer(target: self, action: #selector(handle(doubleTapGesture:)))
doubleTapGesture.numberOfTapsRequired = 2
scrollView.addGestureRecognizer(doubleTapGesture)
singleTapGesture.require(toFail: doubleTapGesture)
}
@objc func handle(dismissPanGesture: UIPanGestureRecognizer) {
let translation = dismissPanGesture.translation(in: view)
if let dismissInteractor = dismissInteractor {
switch dismissPanGesture.state {
case .began:
dismissInteractor.hasStarted = true
dismissInteractor.start(source: imageView)
onDismiss?(.touch, imageView)
case .changed:
dismissInteractor.update(translation.y)
case .cancelled:
dismissInteractor.hasStarted = false
dismissInteractor.cancel()
case .ended:
dismissInteractor.hasStarted = false
dismissInteractor.shouldFinish ? dismissInteractor.finish() : dismissInteractor.cancel()
default:
break
}
}
dismissPanGesture.setTranslation(.zero, in: view)
}
@objc private func handle(singleTapGesture: UIPanGestureRecognizer) {
onDismiss?(.touch, imageView)
}
@objc private func handle(doubleTapGesture: UIPanGestureRecognizer) {
if scrollView.zoomScale == 1 {
let center = doubleTapGesture.location(in: doubleTapGesture.view)
scrollView.zoom(to: zoomedRect(for: scrollView.maximumZoomScale, center: center), animated: true)
} else {
scrollView.setZoomScale(1, animated: true)
}
}
private func isVerticalGesture(_ recognizer: UIPanGestureRecognizer) -> Bool {
let translation = recognizer.translation(in: recognizer.view)
return abs(translation.y) > abs(translation.x)
}
private func zoomedRect(for scale: CGFloat, center: CGPoint) -> CGRect {
var rect: CGRect = .zero
rect.size.height = imageView.frame.height / scale
rect.size.width = imageView.frame.width / scale
let center = scrollView.convert(center, from: imageView)
rect.origin.x = center.x - (rect.width / 2.0)
rect.origin.y = center.y - (rect.height / 2.0)
return rect
}
}
// MARK: - DismissInteractor
final class DismissInteractor: NSObject {
let animationDuration: TimeInterval
var hasStarted = false
var shouldFinish = false
var sourceImageView: UIImageView?
var targetImageView: UIImageView?
private weak var transitionContext: UIViewControllerContextTransitioning?
private var animatingImageView: UIImageView?
private var backgroundView: UIView?
private var toView: UIView?
init(animationDuration: TimeInterval) {
self.animationDuration = animationDuration
}
func start(source imageView: UIImageView) {
sourceImageView = imageView
}
func update(_ translationY: CGFloat) {
targetImageView?.isHidden = true
guard let animatingImageView = animatingImageView else { return }
animatingImageView.center = CGPoint(x: animatingImageView.center.x,
y: animatingImageView.center.y + translationY)
if let toView = transitionContext?.toView() {
shouldFinish = animatingImageView.center.y <= toView.frame.height / 4
|| animatingImageView.center.y >= 3 * toView.frame.height / 4
let factor = min(animatingImageView.center.y, toView.frame.height - animatingImageView.center.y)
backgroundView?.alpha = factor / (toView.frame.height / 2)
}
}
func finish() {
if let targetImageView = targetImageView,
let image = targetImageView.image,
let animatingImageView = animatingImageView {
animatingImageView.frame = animatingImageView.rect(aspectRatio: image.size)
animatingImageView.contentMode = targetImageView.contentMode
let windowFrame = targetImageView.superview?.convert(targetImageView.frame, to: nil)
let frame = windowFrame ?? targetImageView.frame
UIView.animate(withDuration: animationDuration,
delay: 0.0,
options: .curveLinear,
animations: { [weak self] in
self?.animatingImageView?.frame = frame
self?.backgroundView?.alpha = 0.0
}, completion: { [weak self] _ in
self?.completeTransition(true)
})
} else if let toView = transitionContext?.toView() {
let frame = toView.frame.offsetBy(dx: 0, dy: toView.frame.height)
UIView.animate(withDuration: animationDuration,
animations: { [weak self] in
self?.animatingImageView?.frame = frame
self?.backgroundView?.alpha = 0.0
}, completion: { [weak self] _ in
self?.completeTransition(true)
})
} else {
completeTransition(true)
}
}
func cancel() {
if let sourceImageView = sourceImageView {
let frame = sourceImageView.frame
UIView.animate(withDuration: animationDuration,
delay: 0.0,
options: .curveLinear,
animations: { [weak self] in
self?.animatingImageView?.frame = frame
self?.backgroundView?.alpha = 1.0
}, completion: { [weak self] _ in
self?.completeTransition(false)
})
} else {
completeTransition(false)
}
}
}
// MARK: - Privates
extension DismissInteractor {
private func completeTransition(_ didComplete: Bool) {
targetImageView?.isHidden = false
animatingImageView?.removeFromSuperview()
backgroundView?.removeFromSuperview()
toView?.removeFromSuperview()
transitionContext?.view(forKey: .to)?.removeFromSuperview()
transitionContext?.completeTransition(didComplete)
transitionContext = nil
}
}
// MARK: - UIViewControllerInteractiveTransitioning
extension DismissInteractor: UIViewControllerInteractiveTransitioning {
func startInteractiveTransition(_ transitionContext: UIViewControllerContextTransitioning) {
toView = transitionContext.toView(snapshotted: true)
guard let toView = toView else {
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
return
}
self.transitionContext = transitionContext
let containerView = transitionContext.containerView
containerView.addSubview(toView)
backgroundView = UIView(frame: toView.bounds)
backgroundView?.backgroundColor = .black
containerView.addSubview(backgroundView!)
if let sourceImageView = sourceImageView {
animatingImageView = UIImageView(frame: sourceImageView.frame)
animatingImageView?.contentMode = sourceImageView.contentMode
animatingImageView?.image = sourceImageView.image
animatingImageView?.clipsToBounds = true
containerView.addSubview(animatingImageView!)
}
}
}
// MARK: - CircularLoaderView
final class CircularLoaderView: UIView {
lazy var circlePathLayer = CAShapeLayer()
let circleRadius: CGFloat = 20.0
var progress: CGFloat {
get {
return circlePathLayer.strokeEnd
}
set {
if newValue > 1 {
circlePathLayer.strokeEnd = 1
} else if newValue < 0 {
circlePathLayer.strokeEnd = 0
} else {
circlePathLayer.strokeEnd = newValue
}
}
}
override init(frame: CGRect) {
super.init(frame: frame)
setupViews()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
setupViews()
}
override func layoutSubviews() {
super.layoutSubviews()
circlePathLayer.frame = bounds
circlePathLayer.path = circlePath.cgPath
}
}
// MARK: - Privates
extension CircularLoaderView {
private var circleFrame: CGRect {
var frame = CGRect(x: 0, y: 0, width: 2 * circleRadius, height: 2 * circleRadius)
let circlePathBounds = circlePathLayer.bounds
frame.origin.x = circlePathBounds.midX - frame.midX
frame.origin.y = circlePathBounds.midY - frame.midY
return frame
}
private var circlePath: UIBezierPath {
return UIBezierPath(ovalIn: circleFrame)
}
private func setupViews() {
circlePathLayer.frame = bounds
circlePathLayer.lineWidth = 2
circlePathLayer.fillColor = UIColor.clear.cgColor
circlePathLayer.strokeColor = UIColor.white.cgColor
progress = 0
layer.addSublayer(circlePathLayer)
backgroundColor = .white
}
}
private extension UIViewControllerContextTransitioning {
func toView(snapshotted: Bool = false) -> UIView? {
let viewForToKey = view(forKey: .to)
let viewFromVC = viewController(forKey: .to)?.view
if snapshotted {
return viewForToKey ?? viewFromVC?.snapshotView(afterScreenUpdates: true)
}
return viewForToKey ?? viewFromVC
}
func fromView(snapshotted: Bool = false) -> UIView? {
let viewForFromKey = view(forKey: .from)
let viewFromVC = viewController(forKey: .from)?.view
if snapshotted {
return viewForFromKey ?? viewFromVC?.snapshotView(afterScreenUpdates: true)
}
return viewForFromKey ?? viewFromVC
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment