|
@@ -0,0 +1,905 @@
|
|
|
+//
|
|
|
+// 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.
|
|
|
+//
|
|
|
+
|
|
|
+// MARK: - MediaBrowserViewControllerDataSource protocol
|
|
|
+/// Protocol to supply media browser contents.
|
|
|
+public protocol MediaBrowserViewControllerDataSource: class {
|
|
|
+
|
|
|
+ /**
|
|
|
+ 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 = (_ 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 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 targetFrameForDismissal(_ mediaBrowser: MediaBrowserViewController) -> CGRect? { return nil }
|
|
|
+}
|
|
|
+
|
|
|
+// MARK: - MediaBrowserViewControllerDelegate protocol
|
|
|
+
|
|
|
+public protocol MediaBrowserViewControllerDelegate: class {
|
|
|
+
|
|
|
+ /**
|
|
|
+ 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 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
|
|
|
+ }()
|
|
|
+
|
|
|
+ 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)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 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
|
|
|
+
|
|
|
+ populateContentViews()
|
|
|
+
|
|
|
+ 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 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 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) {
|
|
|
+
|
|
|
+ self.controlViews.forEach { $0.alpha = hide ? 0.0 : 1.0 }
|
|
|
+ /*
|
|
|
+ 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
|
|
|
+
|
|
|
+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)
|
|
|
+
|
|
|
+ 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)
|
|
|
+
|
|
|
+ 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
|
|
|
+ }
|
|
|
+ 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
|
|
|
+ }
|
|
|
+}
|