// // 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 . // protocol NCViewerImageViewControllerDataSource: class { func numberOfItems(in viewerImageViewController: NCViewerImageViewController) -> Int func viewerImageViewController(_ viewerImageViewController: NCViewerImageViewController, imageAt index: Int, completion: @escaping (_ index: Int, _ image: UIImage?, _ metadata: tableMetadata, _ zoomScale: ZoomScale?, _ error: Error?) -> Void) func targetFrameForDismissal(_ viewerImageViewController: NCViewerImageViewController) -> CGRect? } extension NCViewerImageViewControllerDataSource { public func targetFrameForDismissal(_ viewerImageViewController: NCViewerImageViewController) -> CGRect? { return nil } } // MARK: - NCViewerImageViewControllerDelegate protocol protocol NCViewerImageViewControllerDelegate: class { func viewerImageViewController(_ viewerImageViewController: NCViewerImageViewController, willChangeFocusTo index: Int, view: NCViewerImageContentView, metadata: tableMetadata) func viewerImageViewController(_ viewerImageViewController: NCViewerImageViewController, didChangeFocusTo index: Int, view: NCViewerImageContentView, metadata: tableMetadata) func viewerImageViewControllerTap(_ viewerImageViewController: NCViewerImageViewController, metadata: tableMetadata) func viewerImageViewControllerLongPressBegan(_ viewerImageViewController: NCViewerImageViewController, metadata: tableMetadata) func viewerImageViewControllerLongPressEnded(_ viewerImageViewController: NCViewerImageViewController, metadata: tableMetadata) func viewerImageViewControllerDismiss() } extension NCViewerImageViewControllerDelegate { func viewerImageViewController(_ viewerImageViewController: NCViewerImageViewController, didChangeFocusTo index: Int, view: NCViewerImageContentView, metadata: tableMetadata) {} func viewerImageViewController(_ viewerImageViewController: NCViewerImageViewController, willChangeFocusTo index: Int, view: NCViewerImageContentView, metadata: tableMetadata) {} } public class NCViewerImageViewController: UIViewController { // MARK: - Exposed Enumerations public enum GestureDirection { // Horizontal (left - right) gestures. case horizontal // Vertical (up - down) gestures. case vertical } 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 } 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 weak var dataSource: NCViewerImageViewControllerDataSource? weak var delegate: NCViewerImageViewControllerDelegate? var gestureDirection: GestureDirection = .horizontal var contentTransformer: NCViewerImageContentTransformer = NCViewerImageDefaultContentTransformers.horizontalMoveInOut { didSet { NCViewerImageContentView.contentTransformer = contentTransformer contentViews.forEach({ $0.updateTransform() }) } } var drawOrder: ContentDrawOrder = .previousToNext { didSet { if oldValue != drawOrder { mediaContainerView.exchangeSubview(at: 0, withSubviewAt: 2) } } } var browserStyle: BrowserStyle = .carousel // Gap between consecutive media items. Default is `50.0`. var gapBetweenMediaViews: CGFloat = Constants.gapBetweenContents { didSet { NCViewerImageContentView.interItemSpacing = gapBetweenMediaViews contentViews.forEach({ $0.updateTransform() }) } } // Enable or disable interactive dismissal. Default is enabled. 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 } guard let mediaView = self.mediaView(at: 1) else { return } mediaView.zoomScaleOne() self.delegate?.viewerImageViewControllerTap(self, metadata: mediaView.metadata) } @objc private func longpressGestureEvent(_ recognizer: UITapGestureRecognizer) { guard !dismissController.interactionInProgress else { return } guard let mediaView = self.mediaView(at: 1) else { return } if recognizer.state == UIGestureRecognizer.State.began { mediaView.zoomScaleOne() self.delegate?.viewerImageViewControllerLongPressBegan(self, metadata: mediaView.metadata) } if recognizer.state == UIGestureRecognizer.State.ended { self.delegate?.viewerImageViewControllerLongPressEnded(self, metadata: mediaView.metadata) } } } // 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, willChangeFocusTo: index, view: nextView, metadata: nextView.metadata) } 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, willChangeFocusTo: index, view: previousView, metadata: previousView.metadata) } else if middleView.position == 0 { delegate?.viewerImageViewController(self, didChangeFocusTo: index, view: middleView, metadata: middleView.metadata) } } 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, metadata, zoom, _) in guard let strongSelf = self else { return } if index == strongSelf.sanitizeIndex(contentView.index) { if image != nil { contentView.image = image contentView.metadata = metadata 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 } }