Files
LCEssentials/Sources/LCEssentials/ImageZoom/ImageZoom.swift
Daniel Arantes Loverde 0910973e9a documentation
2025-06-23 09:48:57 -03:00

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