// // NCViewerImageViewController.swift // Nextcloud // // Created by Suraj Thomas K on 7/10/18 Copyright © 2018 Al Tayer Group LLC.. // Modify for Nextcloud by Marino Faggiana on 04/03/2020. // // Author Marino Faggiana // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. If not, see . // public protocol NCViewerImageViewControllerDataSource: 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 func numberOfItems(in viewerImageViewController: NCViewerImageViewController) -> Int func viewerImageViewController(_ viewerImageViewController: NCViewerImageViewController, imageAt index: Int, completion: @escaping CompletionBlock) func targetFrameForDismissal(_ viewerImageViewController: NCViewerImageViewController) -> CGRect? } extension NCViewerImageViewControllerDataSource { public func targetFrameForDismissal(_ viewerImageViewController: NCViewerImageViewController) -> CGRect? { return nil } } // MARK: - NCViewerImageViewControllerDelegate protocol public protocol NCViewerImageViewControllerDelegate: class { func viewerImageViewController(_ viewerImageViewController: NCViewerImageViewController, didChangeFocusTo index: Int, view: NCViewerImageContentView) func viewerImageViewControllerTap(_ viewerImageViewController: NCViewerImageViewController) func viewerImageViewControllerDismiss() } extension NCViewerImageViewControllerDelegate { public func viewerImageViewController(_ viewerImageViewController: NCViewerImageViewController, didChangeFocusTo index: Int, view: NCViewerImageContentView) {} } public class NCViewerImageViewController: 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 } // MARK: - Exposed variables /// Data-source object to supply media browser contents. public weak var dataSource: NCViewerImageViewControllerDataSource? /// Delegate object to get callbacks on media browser events. public weak var delegate: NCViewerImageViewControllerDelegate? /// Gesture direction. Default is `horizontal`. public var gestureDirection: GestureDirection = .horizontal /// Content transformer closure. Default is `horizontalMoveInOut`. public var contentTransformer: NCViewerImageContentTransformer = NCViewerImageDefaultContentTransformers.horizontalMoveInOut { didSet { NCViewerImageContentView.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 { NCViewerImageContentView.interItemSpacing = gapBetweenMediaViews contentViews.forEach({ $0.updateTransform() }) } } /// Enable or disable interactive dismissal. Default is enabled. public var enableInteractiveDismissal: Bool = true /// Item index of the current item. In range `0.. 0 { updateContents(of: mediaView) } } if drawOrder == .nextToPrevious { mediaContainerView.exchangeSubview(at: 0, withSubviewAt: 2) } } } // MARK: - Gesture Recognizers extension NCViewerImageViewController { @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 let mediaView = self.mediaView(at: 1) { mediaView.zoomScaleOne() } self.delegate?.viewerImageViewControllerTap(self) } } // MARK: - Updating View Positions extension NCViewerImageViewController { @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?.viewerImageViewController(self, didChangeFocusTo: index, view: nextView) } 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?.viewerImageViewController(self, didChangeFocusTo: index, view: previousView) } } 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: NCViewerImageContentView) { contentView.image = nil let convertedIndex = sanitizeIndex(contentView.index) contentView.isLoading = true dataSource?.viewerImageViewController( 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) -> NCViewerImageContentView? { guard index < contentViews.count else { assertionFailure("Content views does not have this many views. : \(index)") return nil } return contentViews[index] } } // MARK: - UIGestureRecognizerDelegate extension NCViewerImageViewController: 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? NCViewerImageContentView { return scrollView.zoomScale == 1.0 } return false } public func gestureRecognizer( _ gestureRecognizer: UIGestureRecognizer, shouldRequireFailureOf otherGestureRecognizer: UIGestureRecognizer ) -> Bool { if gestureRecognizer is UITapGestureRecognizer { return otherGestureRecognizer.view is NCViewerImageContentView } return false } }