Files
Lightbox/Source/LightboxController.swift
T

469 lines
13 KiB
Swift

import UIKit
public protocol LightboxControllerPageDelegate: class {
func lightboxController(_ controller: LightboxController, didMoveToPage page: Int)
}
public protocol LightboxControllerDismissalDelegate: class {
func lightboxControllerWillDismiss(_ controller: LightboxController)
}
public protocol LightboxControllerTouchDelegate: class {
func lightboxController(_ controller: LightboxController, didTouch image: LightboxImage, at index: Int)
}
open class LightboxController: UIViewController {
// MARK: - Internal views
lazy var scrollView: UIScrollView = { [unowned self] in
let scrollView = UIScrollView()
scrollView.isPagingEnabled = false
scrollView.delegate = self
scrollView.showsHorizontalScrollIndicator = false
scrollView.decelerationRate = UIScrollView.DecelerationRate.fast
return scrollView
}()
lazy var overlayTapGestureRecognizer: UITapGestureRecognizer = { [unowned self] in
let gesture = UITapGestureRecognizer()
gesture.addTarget(self, action: #selector(overlayViewDidTap(_:)))
return gesture
}()
lazy var effectView: UIVisualEffectView = {
let effect = UIBlurEffect(style: .dark)
let view = UIVisualEffectView(effect: effect)
view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
return view
}()
lazy var backgroundView: UIImageView = {
let view = UIImageView()
view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
return view
}()
// MARK: - Public views
open fileprivate(set) lazy var headerView: HeaderView = { [unowned self] in
let view = HeaderView()
view.delegate = self
return view
}()
open fileprivate(set) lazy var footerView: FooterView = { [unowned self] in
let view = FooterView()
view.delegate = self
return view
}()
open fileprivate(set) lazy var overlayView: UIView = { [unowned self] in
let view = UIView(frame: CGRect.zero)
let gradient = CAGradientLayer()
let colors = [UIColor(hex: "090909").withAlphaComponent(0), UIColor(hex: "040404")]
view.addGradientLayer(colors)
view.alpha = 0
return view
}()
// MARK: - Properties
open fileprivate(set) var currentPage = 0 {
didSet {
currentPage = min(numberOfPages - 1, max(0, currentPage))
footerView.updatePage(currentPage + 1, numberOfPages)
footerView.updateText(pageViews[currentPage].image.text)
if currentPage == numberOfPages - 1 {
seen = true
}
reconfigurePagesForPreload()
pageDelegate?.lightboxController(self, didMoveToPage: currentPage)
if let image = pageViews[currentPage].imageView.image, dynamicBackground {
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.125) {
self.loadDynamicBackground(image)
}
}
}
}
open var numberOfPages: Int {
return pageViews.count
}
open var dynamicBackground: Bool = false {
didSet {
if dynamicBackground == true {
effectView.frame = view.frame
backgroundView.frame = effectView.frame
view.insertSubview(effectView, at: 0)
view.insertSubview(backgroundView, at: 0)
} else {
effectView.removeFromSuperview()
backgroundView.removeFromSuperview()
}
}
}
open var spacing: CGFloat = 20 {
didSet {
configureLayout(view.bounds.size)
}
}
open var images: [LightboxImage] {
get {
return pageViews.map { $0.image }
}
set(value) {
initialImages = value
configurePages(value)
}
}
open weak var pageDelegate: LightboxControllerPageDelegate?
open weak var dismissalDelegate: LightboxControllerDismissalDelegate?
open weak var imageTouchDelegate: LightboxControllerTouchDelegate?
open internal(set) var presented = false
open fileprivate(set) var seen = false
lazy var transitionManager: LightboxTransition = LightboxTransition()
var pageViews = [PageView]()
var statusBarHidden = false
fileprivate var initialImages: [LightboxImage]
fileprivate let initialPage: Int
// MARK: - Initializers
public init(images: [LightboxImage] = [], startIndex index: Int = 0) {
self.initialImages = images
self.initialPage = index
super.init(nibName: nil, bundle: nil)
}
public required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - View lifecycle
open override func viewDidLoad() {
super.viewDidLoad()
// 9 July 2020: @3lvis
// Lightbox hasn't been optimized to be used in presentation styles other than fullscreen.
modalPresentationStyle = .fullScreen
statusBarHidden = UIApplication.shared.isStatusBarHidden
view.backgroundColor = UIColor.black
transitionManager.lightboxController = self
transitionManager.scrollView = scrollView
transitioningDelegate = transitionManager
[scrollView, overlayView, headerView, footerView].forEach { view.addSubview($0) }
overlayView.addGestureRecognizer(overlayTapGestureRecognizer)
configurePages(initialImages)
goTo(initialPage, animated: false)
}
open override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
scrollView.frame = view.bounds
footerView.frame.size = CGSize(
width: view.bounds.width,
height: 100
)
footerView.frame.origin = CGPoint(
x: 0,
y: view.bounds.height - footerView.frame.height
)
headerView.frame = CGRect(
x: 0,
y: 16,
width: view.bounds.width,
height: 100
)
if !presented {
presented = true
configureLayout(view.bounds.size)
}
}
open override var prefersStatusBarHidden: Bool {
return LightboxConfig.hideStatusBar
}
// MARK: - Rotation
override open func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
coordinator.animate(alongsideTransition: { _ in
self.configureLayout(size)
}, completion: nil)
}
// MARK: - Configuration
func configurePages(_ images: [LightboxImage]) {
pageViews.forEach { $0.removeFromSuperview() }
pageViews = []
let preloadIndicies = calculatePreloadIndicies()
for i in 0..<images.count {
let pageView = PageView(image: preloadIndicies.contains(i) ? images[i] : LightboxImageStub())
pageView.pageViewDelegate = self
scrollView.addSubview(pageView)
pageViews.append(pageView)
}
configureLayout(view.bounds.size)
}
func reconfigurePagesForPreload() {
let preloadIndicies = calculatePreloadIndicies()
for i in 0..<initialImages.count {
let pageView = pageViews[i]
if preloadIndicies.contains(i) {
if type(of: pageView.image) == LightboxImageStub.self {
pageView.update(with: initialImages[i])
}
} else {
if type(of: pageView.image) != LightboxImageStub.self {
pageView.update(with: LightboxImageStub())
}
}
}
}
// MARK: - Pagination
open func goTo(_ page: Int, animated: Bool = true) {
guard page >= 0 && page < numberOfPages else {
return
}
currentPage = page
var offset = scrollView.contentOffset
offset.x = CGFloat(page) * (scrollView.frame.width + spacing)
let shouldAnimated = view.window != nil ? animated : false
scrollView.setContentOffset(offset, animated: shouldAnimated)
}
open func next(_ animated: Bool = true) {
goTo(currentPage + 1, animated: animated)
}
open func previous(_ animated: Bool = true) {
goTo(currentPage - 1, animated: animated)
}
// MARK: - Actions
@objc func overlayViewDidTap(_ tapGestureRecognizer: UITapGestureRecognizer) {
footerView.expand(false)
}
// MARK: - Layout
open func configureLayout(_ size: CGSize) {
scrollView.frame.size = size
scrollView.contentSize = CGSize(
width: size.width * CGFloat(numberOfPages) + spacing * CGFloat(numberOfPages - 1),
height: size.height)
scrollView.contentOffset = CGPoint(x: CGFloat(currentPage) * (size.width + spacing), y: 0)
for (index, pageView) in pageViews.enumerated() {
var frame = scrollView.bounds
frame.origin.x = (frame.width + spacing) * CGFloat(index)
pageView.frame = frame
pageView.configureLayout()
if index != numberOfPages - 1 {
pageView.frame.size.width += spacing
}
}
[headerView, footerView].forEach { ($0 as AnyObject).configureLayout() }
overlayView.frame = scrollView.frame
overlayView.resizeGradientLayer()
}
fileprivate func loadDynamicBackground(_ image: UIImage) {
backgroundView.image = image
backgroundView.layer.add(CATransition(), forKey: "fade")
}
func toggleControls(pageView: PageView?, visible: Bool, duration: TimeInterval = 0.1, delay: TimeInterval = 0) {
let alpha: CGFloat = visible ? 1.0 : 0.0
pageView?.playButton.isHidden = !visible
UIView.animate(withDuration: duration, delay: delay, options: [], animations: {
self.headerView.alpha = alpha
self.footerView.alpha = alpha
pageView?.playButton.alpha = alpha
}, completion: nil)
}
// MARK: - Helper functions
func calculatePreloadIndicies () -> [Int] {
var preloadIndicies: [Int] = []
let preload = LightboxConfig.preload
if preload > 0 {
let lb = max(0, currentPage - preload)
let rb = min(initialImages.count, currentPage + preload)
for i in lb..<rb {
preloadIndicies.append(i)
}
} else {
preloadIndicies = [Int](0..<initialImages.count)
}
return preloadIndicies
}
}
// MARK: - UIScrollViewDelegate
extension LightboxController: UIScrollViewDelegate {
public func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
var speed: CGFloat = velocity.x < 0 ? -2 : 2
if velocity.x == 0 {
speed = 0
}
let pageWidth = scrollView.bounds.width + spacing
var x = scrollView.contentOffset.x + speed * 60.0
if speed > 0 {
x = ceil(x / pageWidth) * pageWidth
} else if speed < -0 {
x = floor(x / pageWidth) * pageWidth
} else {
x = round(x / pageWidth) * pageWidth
}
targetContentOffset.pointee.x = x
currentPage = Int(x / pageWidth)
}
}
// MARK: - PageViewDelegate
extension LightboxController: PageViewDelegate {
func remoteImageDidLoad(_ image: UIImage?, imageView: UIImageView) {
guard let image = image, dynamicBackground else {
return
}
let imageViewFrame = imageView.convert(imageView.frame, to: view)
guard view.frame.intersects(imageViewFrame) else {
return
}
loadDynamicBackground(image)
}
func pageViewDidZoom(_ pageView: PageView) {
let duration = pageView.hasZoomed ? 0.1 : 0.5
toggleControls(pageView: pageView, visible: !pageView.hasZoomed, duration: duration, delay: 0.5)
}
func pageView(_ pageView: PageView, didTouchPlayButton videoURL: URL) {
LightboxConfig.handleVideo(self, videoURL)
}
func pageViewDidTouch(_ pageView: PageView) {
guard !pageView.hasZoomed else { return }
imageTouchDelegate?.lightboxController(self, didTouch: images[currentPage], at: currentPage)
let visible = (headerView.alpha == 1.0)
toggleControls(pageView: pageView, visible: !visible)
}
}
// MARK: - HeaderViewDelegate
extension LightboxController: HeaderViewDelegate {
func headerView(_ headerView: HeaderView, didPressDeleteButton deleteButton: UIButton) {
deleteButton.isEnabled = false
guard numberOfPages != 1 else {
pageViews.removeAll()
self.headerView(headerView, didPressCloseButton: headerView.closeButton)
return
}
let prevIndex = currentPage
if currentPage == numberOfPages - 1 {
previous()
} else {
next()
currentPage -= 1
}
self.initialImages.remove(at: prevIndex)
self.pageViews.remove(at: prevIndex).removeFromSuperview()
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.5) {
self.configureLayout(self.view.bounds.size)
self.currentPage = Int(self.scrollView.contentOffset.x / self.view.bounds.width)
deleteButton.isEnabled = true
}
}
func headerView(_ headerView: HeaderView, didPressCloseButton closeButton: UIButton) {
closeButton.isEnabled = false
presented = false
dismissalDelegate?.lightboxControllerWillDismiss(self)
dismiss(animated: true, completion: nil)
}
}
// MARK: - FooterViewDelegate
extension LightboxController: FooterViewDelegate {
public func footerView(_ footerView: FooterView, didExpand expanded: Bool) {
UIView.animate(withDuration: 0.25, animations: {
self.overlayView.alpha = expanded ? 1.0 : 0.0
self.headerView.deleteButton.alpha = expanded ? 0.0 : 1.0
})
}
}