245 lines
10 KiB
Swift
245 lines
10 KiB
Swift
//
|
|
// Copyright (c) 2023 Loverde Co.
|
|
//
|
|
// 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
|
|
|
|
#if os(iOS) || os(macOS)
|
|
/// A protocol for delegates of `ImageZoomController` to provide callbacks for zoom and close events.
|
|
@objc public protocol ImageZoomControllerDelegate {
|
|
/// Called when the image in the controller is zoomed.
|
|
/// - Parameters:
|
|
/// - controller: The `ImageZoomController` instance.
|
|
/// - image: The `UIImage` currently displayed and being zoomed.
|
|
@objc optional func imageZoomController(controller: ImageZoomController, didZoom image: UIImage?)
|
|
|
|
/// Called when the image zoom controller is closed.
|
|
/// - Parameters:
|
|
/// - controller: The `ImageZoomController` instance.
|
|
/// - image: The `UIImage` that was displayed when the controller was closed.
|
|
@objc optional func imageZoomController(controller: ImageZoomController, didClose image: UIImage?)
|
|
}
|
|
|
|
/// A `UIViewController` that allows users to zoom and pan an image, with an option to dismiss by dragging.
|
|
public class ImageZoomController: UIViewController {
|
|
|
|
/// A private lazy `UIView` that serves as a black background with partial opacity.
|
|
fileprivate lazy var blackView: UIView = {
|
|
$0.isOpaque = true
|
|
$0.translatesAutoresizingMaskIntoConstraints = false
|
|
$0.backgroundColor = UIColor.black
|
|
$0.alpha = 0.8
|
|
return $0
|
|
}(UIView())
|
|
|
|
/// A private lazy `UIScrollView` to enable zooming and panning of the image.
|
|
fileprivate lazy var scrollView: UIScrollView = {
|
|
$0.isOpaque = true
|
|
$0.translatesAutoresizingMaskIntoConstraints = false
|
|
$0.delegate = self
|
|
return $0
|
|
}(UIScrollView())
|
|
|
|
/// A private lazy `UIButton` to close the image zoom controller.
|
|
fileprivate lazy var closeButton: UIButton = {
|
|
$0.isOpaque = true
|
|
$0.translatesAutoresizingMaskIntoConstraints = false
|
|
if #available(iOS 13.0, *) {
|
|
$0.setImage(UIImage(systemName: "xmark"), for: .normal)
|
|
$0.tintColor = UIColor.white
|
|
}
|
|
$0.addTarget(self, action: #selector(self.close), for: .touchUpInside)
|
|
return $0
|
|
}(UIButton(type: .custom))
|
|
|
|
/// The `UIImageView` that displays the image.
|
|
lazy var imageView: UIImageView = {
|
|
$0.isOpaque = true
|
|
$0.translatesAutoresizingMaskIntoConstraints = false
|
|
$0.isUserInteractionEnabled = true
|
|
return $0
|
|
}(UIImageView())
|
|
|
|
/// The minimum zoom scale allowed for the image. Defaults to `1.0`.
|
|
public var minimumZoomScale: CGFloat = 1.0
|
|
|
|
/// The maximum zoom scale allowed for the image. Defaults to `6.0`.
|
|
public var maximumZoomScale: CGFloat = 6.0
|
|
|
|
/// A boolean value indicating whether a pan gesture can be used to dismiss the controller. Defaults to `true`.
|
|
public var addGestureToDismiss: Bool = true
|
|
|
|
/// The delegate for the `ImageZoomController`.
|
|
public weak var delegate: ImageZoomControllerDelegate?
|
|
|
|
private var minimumVelocityToHide: CGFloat = 1500
|
|
private var minimumScreenRatioToHide: CGFloat = 0.5
|
|
private var animationDuration: TimeInterval = 0.2
|
|
|
|
/// Initializes a new `ImageZoomController` with a specified image.
|
|
/// - Parameter withImage: The `UIImage` to be displayed and zoomed.
|
|
public init(_ withImage: UIImage) {
|
|
super.init(nibName: nil, bundle: nil)
|
|
|
|
self.addComponentsAndConstraints()
|
|
|
|
self.imageView.image = withImage
|
|
self.imageView.addAspectRatioConstraint()
|
|
|
|
self.scrollView.minimumZoomScale = self.minimumZoomScale
|
|
self.scrollView.maximumZoomScale = self.maximumZoomScale
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
override public func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
}
|
|
|
|
public override func viewWillAppear(_ animated: Bool) {
|
|
super.viewWillAppear(animated)
|
|
|
|
if addGestureToDismiss {
|
|
let panGesture = UIPanGestureRecognizer(target: self, action: #selector(onPan(_:)))
|
|
view.addGestureRecognizer(panGesture)
|
|
}
|
|
}
|
|
|
|
/// Adds the subviews and sets up their constraints.
|
|
fileprivate func addComponentsAndConstraints() {
|
|
|
|
scrollView.addSubview(imageView, translatesAutoresizingMaskIntoConstraints: false)
|
|
view.addSubviews([blackView, scrollView, closeButton])
|
|
|
|
blackView
|
|
.setConstraintsTo(view, .all, 0, true)
|
|
|
|
scrollView
|
|
.setConstraintsTo(view, .all, 0, true)
|
|
|
|
imageView.setHeight(min: 200)
|
|
imageView
|
|
.setConstraintsTo(scrollView, .centerX, 0)
|
|
.setConstraints(.centerY, 0)
|
|
|
|
closeButton
|
|
.setConstraintsTo(view, .leading, 20, true)
|
|
.setConstraints(.top, 0)
|
|
.setWidth(size: 50.0)
|
|
.setHeight(size: 50.0)
|
|
}
|
|
|
|
/// Presents the `ImageZoomController` from the top-most view controller.
|
|
/// - Parameter completion: An optional closure to be executed after the presentation is complete.
|
|
public func present(completion: (()->())? = nil) {
|
|
guard let viewController = LCEssentials.getTopViewController(aboveBars: true) else {
|
|
fatalError("Ops! Look like it doesnt have a ViewController")
|
|
}
|
|
self.modalTransitionStyle = .coverVertical
|
|
self.modalPresentationStyle = .overFullScreen
|
|
viewController.present(self, animated: true) {
|
|
completion?()
|
|
}
|
|
}
|
|
|
|
/// Dismisses the `ImageZoomController`.
|
|
/// - Parameter completion: An optional closure to be executed after the dismissal is complete.
|
|
public func dismiss(completion: (()->())? = nil) {
|
|
self.dismiss(animated: true) {
|
|
completion?()
|
|
}
|
|
}
|
|
|
|
/// Action method for the close button.
|
|
/// Notifies the delegate that the controller is closing and then dismisses itself.
|
|
@objc private func close(){
|
|
delegate?.imageZoomController?(controller: self, didClose: self.imageView.image)
|
|
self.dismiss()
|
|
}
|
|
|
|
/// Slides the view vertically to a specified Y-coordinate.
|
|
/// - Parameter y: The Y-coordinate to slide the view to.
|
|
private func slideViewVerticallyTo(_ y: CGFloat) {
|
|
self.view.frame.origin = CGPoint(x: 0, y: y)
|
|
}
|
|
|
|
/// Handles pan gestures for dismissing the view.
|
|
/// - Parameter panGesture: The `UIPanGestureRecognizer` instance.
|
|
@objc private func onPan(_ panGesture: UIPanGestureRecognizer) {
|
|
|
|
switch panGesture.state {
|
|
case .began, .changed:
|
|
// If pan started or is ongoing then
|
|
// slide the view to follow the finger
|
|
let translation = panGesture.translation(in: view)
|
|
let y = max(0, translation.y)
|
|
slideViewVerticallyTo(y)
|
|
|
|
case .ended:
|
|
// If pan ended, decide it we should close or reset the view
|
|
// based on the final position and the speed of the gesture
|
|
let translation = panGesture.translation(in: view)
|
|
let velocity = panGesture.velocity(in: view)
|
|
let closing = (translation.y > self.view.frame.size.height * minimumScreenRatioToHide) || (velocity.y > minimumVelocityToHide)
|
|
if closing {
|
|
UIView.animate(withDuration: animationDuration, animations: {
|
|
// If closing, animate to the bottom of the view
|
|
self.slideViewVerticallyTo(self.view.frame.size.height)
|
|
}, completion: { (isCompleted) in
|
|
if isCompleted {
|
|
// Dismiss the view when it dissapeared
|
|
self.dismiss(animated: false, completion: nil)
|
|
}
|
|
})
|
|
} else {
|
|
// If not closing, reset the view to the top
|
|
UIView.animate(withDuration: animationDuration, animations: {
|
|
self.slideViewVerticallyTo(0)
|
|
})
|
|
}
|
|
|
|
default:
|
|
// If gesture state is undefined, reset the view to the top
|
|
UIView.animate(withDuration: animationDuration, animations: {
|
|
self.slideViewVerticallyTo(0)
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
extension ImageZoomController: UIScrollViewDelegate {
|
|
|
|
/// Tells the delegate that the scroll view has zoomed.
|
|
/// - Parameter scrollView: The scroll view that zoomed.
|
|
public func scrollViewDidZoom(_ scrollView: UIScrollView) {
|
|
delegate?.imageZoomController?(controller: self, didZoom: self.imageView.image)
|
|
}
|
|
|
|
/// Returns the view that will be zoomed when the scroll view is zoomed.
|
|
/// - Parameter scrollView: The scroll view that is zooming.
|
|
/// - Returns: The view to be zoomed, which is the `imageView` in this case.
|
|
public func viewForZooming(in scrollView: UIScrollView) -> UIView? {
|
|
return self.imageView
|
|
}
|
|
}
|
|
#endif
|