AutoCat/AutoCat/ThirdParty/ATGMediaBrowser/MediaBrowserViewController.swift

1023 lines
35 KiB
Swift

//
// MediaBrowserViewController.swift
// ATGMediaBrowser
//
// Created by Suraj Thomas K on 7/10/18.
// Copyright © 2018 Al Tayer Group LLC.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of this software
// and associated documentation files (the "Software"), to deal in the Software without
// restriction, including without limitation the rights to use, copy, modify, merge, publish,
// distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all copies or
// substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
// BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
//
import UIKit
// MARK: - MediaBrowserViewControllerDataSource protocol
/// Protocol to supply media browser contents.
@MainActor
public protocol MediaBrowserViewControllerDataSource: AnyObject {
/**
Completion block for passing requested media image with details.
- parameter index: Index of the requested media.
- parameter image: Image to be passed back to media browser.
- parameter zoomScale: Zoom scale to be applied to the image including min and max levels.
- parameter error: Error received while fetching the media image.
- note:
Remember to pass the index received in the datasource method back.
This index is used to set the image to the correct image view.
*/
typealias CompletionBlock = @MainActor @Sendable (_ index: Int, _ image: UIImage?, _ zoomScale: ZoomScale?, _ error: Error?) -> Void
/**
Method to supply number of items to be shown in media browser.
- parameter mediaBrowser: Reference to media browser object.
- returns: An integer with number of items to be shown in media browser.
*/
func numberOfItems(in mediaBrowser: MediaBrowserViewController) -> Int
/**
Method to supply image for specific index.
- parameter mediaBrowser: Reference to media browser object.
- parameter index: Index of the requested media.
- parameter completion: Completion block to be executed on fetching the media image.
*/
func mediaBrowser(_ mediaBrowser: MediaBrowserViewController, imageAt index: Int, completion: @escaping CompletionBlock)
/**
This **optional method** callback is provided to update the styling of close button.
- parameter mediaBrowser: Reference to media browser object.
- parameter button: Reference to close button
- note:
You can modify the styling of the supplied button, and even add constraints to position
the button relative to it's superview. Remember that if no constraints are applied on the button,
default constraints will be applied on, and will be shown on top-right side of the view.
On top of that you can add target to this button to handle the closebutton event manually. By
default touch-up-inside event is used to close the media browser.
*/
func mediaBrowser(_ mediaBrowser: MediaBrowserViewController, updateCloseButton button: UIButton)
/**
This method is used to get the target frame into which the browser will perform the dismiss transition.
- parameter mediaBrowser: Reference to media browser object.
- note:
If this method is not implemented, the media browser will perform slide up/down transition on dismissal.
*/
func targetFrameForDismissal(_ mediaBrowser: MediaBrowserViewController) -> CGRect?
}
extension MediaBrowserViewControllerDataSource {
public func mediaBrowser(_ mediaBrowser: MediaBrowserViewController, updateCloseButton button: UIButton) {}
public func targetFrameForDismissal(_ mediaBrowser: MediaBrowserViewController) -> CGRect? { return nil }
}
// MARK: - MediaBrowserViewControllerDelegate protocol
@MainActor
public protocol MediaBrowserViewControllerDelegate: AnyObject {
/**
Method invoked on scrolling to next/previous media items.
- parameter mediaBrowser: Reference to media browser object.
- parameter index: Index of the newly focussed media item.
- note:
This method will not be called on first load, and will be called only on swiping left and right.
*/
func mediaBrowser(_ mediaBrowser: MediaBrowserViewController, didChangeFocusTo index: Int)
}
extension MediaBrowserViewControllerDelegate {
public func mediaBrowser(_ mediaBrowser: MediaBrowserViewController, didChangeFocusTo index: Int) {}
}
public class MediaBrowserViewController: UIViewController {
// MARK: - Exposed Enumerations
/**
Enum to hold supported gesture directions.
```
case horizontal
case vertical
```
*/
public enum GestureDirection {
/// Horizontal (left - right) gestures.
case horizontal
/// Vertical (up - down) gestures.
case vertical
}
/**
Enum to hold supported browser styles.
```
case linear
case carousel
```
*/
public enum BrowserStyle {
/// Linear browser with *0* as first index and *numItems-1* as last index.
case linear
/// Carousel browser. The media items are repeated in a circular fashion.
case carousel
}
/**
Enum to hold supported content draw orders.
```
case previousToNext
case nextToPrevious
```
- note:
Remember that this is draw order, not positioning. This order decides which item will
be above or below other items, when they overlap.
*/
public enum ContentDrawOrder {
/// In this mode, media items are rendered in [previous]-[current]-[next] order.
case previousToNext
/// In this mode, media items are rendered in [next]-[current]-[previous] order.
case nextToPrevious
}
/**
Struct to hold support for customize title style
```
font
textColor
```
*/
public struct TitleStyle {
/// Title style font
public var font: UIFont = UIFont.preferredFont(forTextStyle: .subheadline)
/// Title style text color.
public var textColor: UIColor = .white
}
// MARK: - Exposed variables
/// Data-source object to supply media browser contents.
public weak var dataSource: MediaBrowserViewControllerDataSource?
/// Delegate object to get callbacks on media browser events.
public weak var delegate: MediaBrowserViewControllerDelegate?
/// Gesture direction. Default is `horizontal`.
public var gestureDirection: GestureDirection = .horizontal
/// Content transformer closure. Default is `horizontalMoveInOut`.
public var contentTransformer: ContentTransformer = DefaultContentTransformers.horizontalMoveInOut {
didSet {
MediaContentView.contentTransformer = contentTransformer
contentViews.forEach({ $0.updateTransform() })
}
}
/// Content draw order. Default is `previousToNext`.
public var drawOrder: ContentDrawOrder = .previousToNext {
didSet {
if oldValue != drawOrder {
mediaContainerView.exchangeSubview(at: 0, withSubviewAt: 2)
}
}
}
/// Browser style. Default is carousel.
public var browserStyle: BrowserStyle = .carousel
/// Gap between consecutive media items. Default is `50.0`.
public var gapBetweenMediaViews: CGFloat = Constants.gapBetweenContents {
didSet {
MediaContentView.interItemSpacing = gapBetweenMediaViews
contentViews.forEach({ $0.updateTransform() })
}
}
/// Variable to set title style in media browser.
public var titleStyle: TitleStyle = TitleStyle() {
didSet {
configureTitleLabel()
}
}
/// Variable to set title in media browser
public override var title: String? {
didSet {
titleLabel.text = title
}
}
/// Variable to hide/show title control in media browser. Default is false.
public var shouldShowTitle: Bool = false {
didSet {
titleLabel.isHidden = !shouldShowTitle
}
}
/// Variable to hide/show page control in media browser.
public var shouldShowPageControl: Bool = true {
didSet {
pageControl.isHidden = !shouldShowPageControl
}
}
/// Variable to hide/show controls(close & page control). Default is false.
public var hideControls: Bool = false {
didSet {
hideControlViews(hideControls)
}
}
/**
Variable to schedule/cancel auto-hide controls(close & page control). Default is false.
Default delay is `3.0` seconds.
- todo: Update to accept auto-hide-delay.
*/
public var autoHideControls: Bool = false {
didSet {
if autoHideControls {
DispatchQueue.main.asyncAfter(
deadline: .now() + Constants.controlHideDelay,
execute: controlToggleTask
)
} else {
controlToggleTask.cancel()
}
}
}
/// Enable or disable interactive dismissal. Default is enabled.
public var enableInteractiveDismissal: Bool = true
/// Item index of the current item. In range `0..<numMediaItems`
public var currentItemIndex: Int {
return sanitizeIndex(index)
}
// MARK: - Private Enumerations
private enum Constants {
static let gapBetweenContents: CGFloat = 50.0
static let minimumVelocity: CGFloat = 15.0
static let minimumTranslation: CGFloat = 0.1
static let animationDuration = 0.3
static let updateFrameRate: CGFloat = 60.0
static let bounceFactor: CGFloat = 0.1
static let controlHideDelay = 3.0
enum Close {
static let top: CGFloat = 8.0
static let trailing: CGFloat = -8.0
static let height: CGFloat = 30.0
static let minWidth: CGFloat = 30.0
static let contentInsets = UIEdgeInsets(top: 0.0, left: 8.0, bottom: 0.0, right: 8.0)
static let borderWidth: CGFloat = 2.0
static let borderColor: UIColor = .white
static let title = "Close"
}
enum PageControl {
static let bottom: CGFloat = -10.0
static let tintColor: UIColor = .lightGray
static let selectedTintColor: UIColor = .white
}
enum Title {
static let top: CGFloat = 16.0
static let rect: CGRect = CGRect(x: 0, y: 0, width: 30, height: 30)
}
}
// MARK: - Private variables
private(set) var index: Int = 0 {
didSet {
pageControl.currentPage = index
}
}
private var contentViews: [MediaContentView] = []
private var controlViews: [UIView] = []
lazy private var controlToggleTask: DispatchWorkItem = { [unowned self] in
let item = DispatchWorkItem {
self.hideControls = true
}
return item
}()
lazy private var tapGestureRecognizer: UITapGestureRecognizer = { [unowned self] in
let gesture = UITapGestureRecognizer()
gesture.numberOfTapsRequired = 1
gesture.numberOfTouchesRequired = 1
gesture.delegate = self
gesture.addTarget(self, action: #selector(tapGestureEvent(_:)))
return gesture
}()
private var previousTranslation: CGPoint = .zero
private var timer: Timer?
private var distanceToMove: CGFloat = 0.0
lazy private var panGestureRecognizer: UIPanGestureRecognizer = { [unowned self] in
let gesture = UIPanGestureRecognizer()
gesture.minimumNumberOfTouches = 1
gesture.maximumNumberOfTouches = 1
gesture.delegate = self
gesture.addTarget(self, action: #selector(panGestureEvent(_:)))
return gesture
}()
lazy internal private(set) var mediaContainerView: UIView = { [unowned self] in
let container = UIView()
container.backgroundColor = .clear
return container
}()
lazy private var closeButton: UIButton = { [unowned self] in
let button = UIButton()
button.setTitle(Constants.Close.title, for: .normal)
button.contentEdgeInsets = Constants.Close.contentInsets
button.addTarget(self, action: #selector(didTapOnClose(_:)), for: .touchUpInside)
button.layer.cornerRadius = Constants.Close.height * 0.5
button.layer.borderColor = Constants.Close.borderColor.cgColor
button.layer.borderWidth = Constants.Close.borderWidth
return button
}()
lazy private var pageControl: UIPageControl = { [unowned self] in
let pageControl = UIPageControl()
pageControl.hidesForSinglePage = true
pageControl.numberOfPages = numMediaItems
pageControl.currentPageIndicatorTintColor = Constants.PageControl.selectedTintColor
pageControl.tintColor = Constants.PageControl.tintColor
pageControl.currentPage = index
return pageControl
}()
lazy var titleLabel: UILabel = {
let label = UILabel(frame: Constants.Title.rect)
label.font = self.titleStyle.font
label.textColor = self.titleStyle.textColor
label.textAlignment = .center
return label
}()
lazy internal private(set) var visualEffectContainer: UIView = UIView()
lazy private var visualEffectContentView: UIImageView = { [unowned self] in
let imageView = UIImageView(frame: view.frame)
imageView.contentMode = .scaleAspectFill
return imageView
}()
lazy private var blurEffect: UIBlurEffect = {
return UIBlurEffect(style: .dark)
}()
lazy private var visualEffectView: UIVisualEffectView = { [unowned self] in
return UIVisualEffectView(effect: blurEffect)
}()
private var numMediaItems = 0
private lazy var dismissController = DismissAnimationController(
gestureDirection: gestureDirection,
viewController: self
)
// MARK: - Public methods
/// Invoking this method reloads the contents media browser.
public func reloadContentViews() {
numMediaItems = dataSource?.numberOfItems(in: self) ?? 0
if shouldShowPageControl {
pageControl.numberOfPages = numMediaItems
}
for contentView in contentViews {
updateContents(of: contentView)
}
dataSource?.mediaBrowser(self, updateCloseButton: closeButton)
}
// MARK: - Initializers
public init(
index: Int = 0,
dataSource: MediaBrowserViewControllerDataSource,
delegate: MediaBrowserViewControllerDelegate? = nil
) {
self.index = index
self.dataSource = dataSource
self.delegate = delegate
super.init(nibName: nil, bundle: nil)
initialize()
}
public required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
initialize()
}
private func initialize() {
view.backgroundColor = .clear
modalPresentationStyle = .custom
modalTransitionStyle = .crossDissolve
}
}
// MARK: - View Lifecycle and Events
extension MediaBrowserViewController {
override public var prefersStatusBarHidden: Bool {
return true
}
override public func viewDidLoad() {
super.viewDidLoad()
numMediaItems = dataSource?.numberOfItems(in: self) ?? 0
addVisualEffectView()
populateContentViews()
addCloseButton()
addPageControl()
addTitleLabel()
view.addGestureRecognizer(panGestureRecognizer)
view.addGestureRecognizer(tapGestureRecognizer)
}
override public func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
contentViews.forEach({ $0.updateTransform() })
}
override public func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
if !controlToggleTask.isCancelled {
controlToggleTask.cancel()
}
}
public override func viewWillTransition(
to size: CGSize,
with coordinator: UIViewControllerTransitionCoordinator
) {
coordinator.animate(alongsideTransition: { context in
self.contentViews.forEach({ $0.handleChangeInViewSize(to: size) })
}, completion: nil)
super.viewWillTransition(to: size, with: coordinator)
}
private func addVisualEffectView() {
view.addSubview(visualEffectContainer)
visualEffectContainer.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
visualEffectContainer.leadingAnchor.constraint(equalTo: view.leadingAnchor),
visualEffectContainer.trailingAnchor.constraint(equalTo: view.trailingAnchor),
visualEffectContainer.topAnchor.constraint(equalTo: view.topAnchor),
visualEffectContainer.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
visualEffectContainer.addSubview(visualEffectContentView)
visualEffectContentView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
visualEffectContentView.leadingAnchor.constraint(equalTo: visualEffectContainer.leadingAnchor),
visualEffectContentView.trailingAnchor.constraint(equalTo: visualEffectContainer.trailingAnchor),
visualEffectContentView.topAnchor.constraint(equalTo: visualEffectContainer.topAnchor),
visualEffectContentView.bottomAnchor.constraint(equalTo: visualEffectContainer.bottomAnchor)
])
visualEffectContainer.addSubview(visualEffectView)
visualEffectView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
visualEffectView.leadingAnchor.constraint(equalTo: visualEffectContainer.leadingAnchor),
visualEffectView.trailingAnchor.constraint(equalTo: visualEffectContainer.trailingAnchor),
visualEffectView.topAnchor.constraint(equalTo: visualEffectContainer.topAnchor),
visualEffectView.bottomAnchor.constraint(equalTo: visualEffectContainer.bottomAnchor)
])
}
private func populateContentViews() {
view.addSubview(mediaContainerView)
mediaContainerView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
mediaContainerView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
mediaContainerView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
mediaContainerView.topAnchor.constraint(equalTo: view.topAnchor),
mediaContainerView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
MediaContentView.interItemSpacing = gapBetweenMediaViews
MediaContentView.contentTransformer = contentTransformer
contentViews.forEach({ $0.removeFromSuperview() })
contentViews.removeAll()
for i in -1...1 {
let mediaView = MediaContentView(
index: i + index,
position: CGFloat(i),
frame: view.bounds
)
mediaContainerView.addSubview(mediaView)
mediaView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
mediaView.leadingAnchor.constraint(equalTo: mediaContainerView.leadingAnchor),
mediaView.trailingAnchor.constraint(equalTo: mediaContainerView.trailingAnchor),
mediaView.topAnchor.constraint(equalTo: mediaContainerView.topAnchor),
mediaView.bottomAnchor.constraint(equalTo: mediaContainerView.bottomAnchor)
])
contentViews.append(mediaView)
if numMediaItems > 0 {
updateContents(of: mediaView)
}
}
if drawOrder == .nextToPrevious {
mediaContainerView.exchangeSubview(at: 0, withSubviewAt: 2)
}
}
private func addCloseButton() {
view.addSubview(closeButton)
dataSource?.mediaBrowser(self, updateCloseButton: closeButton)
if closeButton.constraints.isEmpty {
closeButton.translatesAutoresizingMaskIntoConstraints = false
var topAnchor = view.topAnchor
if #available(iOS 11.0, *) {
if view.responds(to: #selector(getter: UIView.safeAreaLayoutGuide)) {
topAnchor = view.safeAreaLayoutGuide.topAnchor
}
}
NSLayoutConstraint.activate([
closeButton.topAnchor.constraint(equalTo: topAnchor, constant: Constants.Close.top),
closeButton.trailingAnchor.constraint(
equalTo: view.trailingAnchor,
constant: Constants.Close.trailing
),
closeButton.widthAnchor.constraint(greaterThanOrEqualToConstant: Constants.Close.minWidth),
closeButton.heightAnchor.constraint(equalToConstant: Constants.Close.height)
])
}
controlViews.append(closeButton)
}
private func addPageControl() {
view.addSubview(pageControl)
pageControl.translatesAutoresizingMaskIntoConstraints = false
var bottomAnchor = view.bottomAnchor
if #available(iOS 11.0, *) {
if view.responds(to: #selector(getter: UIView.safeAreaLayoutGuide)) {
bottomAnchor = view.safeAreaLayoutGuide.bottomAnchor
}
}
NSLayoutConstraint.activate([
pageControl.bottomAnchor.constraint(equalTo: bottomAnchor, constant: Constants.PageControl.bottom),
pageControl.centerXAnchor.constraint(equalTo: view.centerXAnchor)
])
controlViews.append(pageControl)
}
private func addTitleLabel() {
view.addSubview(titleLabel)
titleLabel.translatesAutoresizingMaskIntoConstraints = false
var topAnchor = view.topAnchor
if #available(iOS 11.0, *) {
if view.responds(to: #selector(getter: UIView.safeAreaLayoutGuide)) {
topAnchor = view.safeAreaLayoutGuide.topAnchor
}
}
NSLayoutConstraint.activate([
titleLabel.topAnchor.constraint(equalTo: topAnchor, constant: Constants.Title.top),
titleLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor)
])
controlViews.append(titleLabel)
}
private func configureTitleLabel() {
titleLabel.font = self.titleStyle.font
titleLabel.textColor = self.titleStyle.textColor
}
private func hideControlViews(_ hide: Bool) {
UIView.animate(
withDuration: Constants.animationDuration,
delay: 0.0,
options: .beginFromCurrentState,
animations: {
self.controlViews.forEach { $0.alpha = hide ? 0.0 : 1.0 }
},
completion: nil
)
}
@objc private func didTapOnClose(_ sender: UIButton) {
if let targetFrame = dataSource?.targetFrameForDismissal(self) {
dismissController.image = sourceImage()
dismissController.beginTransition()
dismissController.animateToTargetFrame(targetFrame)
} else {
dismiss(animated: true, completion: nil)
}
}
}
// MARK: - Gesture Recognizers
extension MediaBrowserViewController {
@objc private func panGestureEvent(_ recognizer: UIPanGestureRecognizer) {
if dismissController.interactionInProgress {
dismissController.handleInteractiveTransition(recognizer)
return
}
guard numMediaItems > 0 else {
return
}
let translation = recognizer.translation(in: view)
switch recognizer.state {
case .began:
previousTranslation = translation
distanceToMove = 0.0
timer?.invalidate()
timer = nil
case .changed:
moveViews(by: CGPoint(x: translation.x - previousTranslation.x, y: translation.y - previousTranslation.y))
case .ended, .failed, .cancelled:
let velocity = recognizer.velocity(in: view)
var viewsCopy = contentViews
let previousView = viewsCopy.removeFirst()
let middleView = viewsCopy.removeFirst()
let nextView = viewsCopy.removeFirst()
var toMove: CGFloat = 0.0
let directionalVelocity = gestureDirection == .horizontal ? velocity.x : velocity.y
if abs(directionalVelocity) < Constants.minimumVelocity &&
abs(middleView.position) < Constants.minimumTranslation {
toMove = -middleView.position
} else if directionalVelocity < 0.0 {
if middleView.position >= 0.0 {
toMove = -middleView.position
} else {
toMove = -nextView.position
}
} else {
if middleView.position <= 0.0 {
toMove = -middleView.position
} else {
toMove = -previousView.position
}
}
if browserStyle == .linear || numMediaItems <= 1 {
if (middleView.index == 0 && ((middleView.position + toMove) > 0.0)) ||
(middleView.index == (numMediaItems - 1) && (middleView.position + toMove) < 0.0) {
toMove = -middleView.position
}
}
distanceToMove = toMove
if timer == nil {
timer = Timer.scheduledTimer(
timeInterval: 1.0/Double(Constants.updateFrameRate),
target: self,
selector: #selector(update(_:)),
userInfo: nil,
repeats: true
)
}
default:
break
}
previousTranslation = translation
}
@objc private func tapGestureEvent(_ recognizer: UITapGestureRecognizer) {
guard !dismissController.interactionInProgress else {
return
}
if !controlToggleTask.isCancelled {
controlToggleTask.cancel()
}
hideControls = !hideControls
}
}
// MARK: - Updating View Positions
@MainActor
extension MediaBrowserViewController {
@objc private func update(_ timeInterval: TimeInterval) {
guard distanceToMove != 0.0 else {
timer?.invalidate()
timer = nil
return
}
let distance = distanceToMove / (Constants.updateFrameRate * 0.1)
distanceToMove -= distance
moveViewsNormalized(by: CGPoint(x: distance, y: distance))
let translation = CGPoint(
x: distance * (view.frame.size.width + gapBetweenMediaViews),
y: distance * (view.frame.size.height + gapBetweenMediaViews)
)
let directionalTranslation = (gestureDirection == .horizontal) ? translation.x : translation.y
if abs(directionalTranslation) < 0.1 {
moveViewsNormalized(by: CGPoint(x: distanceToMove, y: distanceToMove))
distanceToMove = 0.0
timer?.invalidate()
timer = nil
}
}
private func moveViews(by translation: CGPoint) {
let viewSizeIncludingGap = CGSize(
width: view.frame.size.width + gapBetweenMediaViews,
height: view.frame.size.height + gapBetweenMediaViews
)
let normalizedTranslation = calculateNormalizedTranslation(
translation: translation,
viewSize: viewSizeIncludingGap
)
moveViewsNormalized(by: normalizedTranslation)
}
private func moveViewsNormalized(by normalizedTranslation: CGPoint) {
let isGestureHorizontal = (gestureDirection == .horizontal)
contentViews.forEach({
$0.position += isGestureHorizontal ? normalizedTranslation.x : normalizedTranslation.y
})
var viewsCopy = contentViews
let previousView = viewsCopy.removeFirst()
let middleView = viewsCopy.removeFirst()
let nextView = viewsCopy.removeFirst()
let viewSizeIncludingGap = CGSize(
width: view.frame.size.width + gapBetweenMediaViews,
height: view.frame.size.height + gapBetweenMediaViews
)
let viewSize = isGestureHorizontal ? viewSizeIncludingGap.width : viewSizeIncludingGap.height
let normalizedGap = gapBetweenMediaViews/viewSize
let normalizedCenter = (middleView.frame.size.width / viewSize) * 0.5
let viewCount = contentViews.count
if middleView.position < -(normalizedGap + normalizedCenter) {
index = sanitizeIndex(index + 1)
// Previous item is taken and placed on right/down most side
previousView.position += CGFloat(viewCount)
previousView.index += viewCount
updateContents(of: previousView)
if let image = nextView.image {
self.visualEffectContentView.image = image
}
contentViews.removeFirst()
contentViews.append(previousView)
switch drawOrder {
case .previousToNext:
mediaContainerView.bringSubviewToFront(previousView)
case .nextToPrevious:
mediaContainerView.sendSubviewToBack(previousView)
}
delegate?.mediaBrowser(self, didChangeFocusTo: index)
} else if middleView.position > (1 + normalizedGap - normalizedCenter) {
index = sanitizeIndex(index - 1)
// Next item is taken and placed on left/top most side
nextView.position -= CGFloat(viewCount)
nextView.index -= viewCount
updateContents(of: nextView)
if let image = previousView.image {
self.visualEffectContentView.image = image
}
contentViews.removeLast()
contentViews.insert(nextView, at: 0)
switch drawOrder {
case .previousToNext:
mediaContainerView.sendSubviewToBack(nextView)
case .nextToPrevious:
mediaContainerView.bringSubviewToFront(nextView)
}
delegate?.mediaBrowser(self, didChangeFocusTo: index)
}
}
private func calculateNormalizedTranslation(translation: CGPoint, viewSize: CGSize) -> CGPoint {
guard let middleView = mediaView(at: 1) else {
return .zero
}
var normalizedTranslation = CGPoint(
x: (translation.x)/viewSize.width,
y: (translation.y)/viewSize.height
)
if browserStyle != .carousel || numMediaItems <= 1 {
let isGestureHorizontal = (gestureDirection == .horizontal)
let directionalTranslation = isGestureHorizontal ? normalizedTranslation.x : normalizedTranslation.y
if (middleView.index == 0 && ((middleView.position + directionalTranslation) > 0.0)) ||
(middleView.index == (numMediaItems - 1) && (middleView.position + directionalTranslation) < 0.0) {
if isGestureHorizontal {
normalizedTranslation.x *= Constants.bounceFactor
} else {
normalizedTranslation.y *= Constants.bounceFactor
}
}
}
return normalizedTranslation
}
private func updateContents(of contentView: MediaContentView) {
contentView.image = nil
let convertedIndex = sanitizeIndex(contentView.index)
contentView.isLoading = true
dataSource?.mediaBrowser(
self,
imageAt: convertedIndex,
completion: { [weak self] (index, image, zoom, _) in
guard let strongSelf = self else {
return
}
if index == strongSelf.sanitizeIndex(contentView.index) {
if image != nil {
contentView.image = image
contentView.zoomLevels = zoom
if index == strongSelf.index {
strongSelf.visualEffectContentView.image = image
}
}
contentView.isLoading = false
}
}
)
}
private func sanitizeIndex(_ index: Int) -> Int {
let newIndex = index % numMediaItems
if newIndex < 0 {
return newIndex + numMediaItems
}
return newIndex
}
private func sourceImage() -> UIImage? {
return mediaView(at: 1)?.image
}
private func mediaView(at index: Int) -> MediaContentView? {
guard index < contentViews.count else {
assertionFailure("Content views does not have this many views. : \(index)")
return nil
}
return contentViews[index]
}
}
// MARK: - UIGestureRecognizerDelegate
extension MediaBrowserViewController: UIGestureRecognizerDelegate {
public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
guard enableInteractiveDismissal else {
return true
}
let middleView = mediaView(at: 1)
if middleView?.zoomScale == middleView?.zoomLevels?.minimumZoomScale,
let recognizer = gestureRecognizer as? UIPanGestureRecognizer {
let translation = recognizer.translation(in: recognizer.view)
if gestureDirection == .horizontal {
dismissController.interactionInProgress = abs(translation.y) > abs(translation.x)
} else {
dismissController.interactionInProgress = abs(translation.x) > abs(translation.y)
}
if dismissController.interactionInProgress {
dismissController.image = sourceImage()
}
}
return true
}
public func gestureRecognizer(
_ gestureRecognizer: UIGestureRecognizer,
shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer
) -> Bool {
if gestureRecognizer is UIPanGestureRecognizer,
let scrollView = otherGestureRecognizer.view as? MediaContentView {
return scrollView.zoomScale == 1.0
}
return false
}
public func gestureRecognizer(
_ gestureRecognizer: UIGestureRecognizer,
shouldRequireFailureOf otherGestureRecognizer: UIGestureRecognizer
) -> Bool {
if gestureRecognizer is UITapGestureRecognizer {
return otherGestureRecognizer.view is MediaContentView
}
return false
}
}