import UIKit final class RDEPUBImageViewController: UIViewController { struct Configuration { let image: UIImage let sourceRect: CGRect? let altText: String? let theme: RDEPUBReaderTheme init(image: UIImage, sourceRect: CGRect? = nil, altText: String? = nil, theme: RDEPUBReaderTheme) { self.image = image self.sourceRect = sourceRect self.altText = altText self.theme = theme } } // MARK: - Properties private let configuration: Configuration private let scrollView = UIScrollView() private let imageView = UIImageView() private let closeButton = UIButton(type: .system) private let backgroundView = UIView() private var isShowingChrome = true // MARK: - Init init(configuration: Configuration) { self.configuration = configuration super.init(nibName: nil, bundle: nil) modalPresentationStyle = .fullScreen modalTransitionStyle = .crossDissolve } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } // MARK: - Lifecycle override func viewDidLoad() { super.viewDidLoad() setupBackground() setupScrollView() setupImageView() setupCloseButton() } override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() centerImage() } override var prefersStatusBarHidden: Bool { true } // MARK: - Setup private func setupBackground() { backgroundView.translatesAutoresizingMaskIntoConstraints = false backgroundView.backgroundColor = configuration.theme.imageViewerBackgroundColor view.addSubview(backgroundView) NSLayoutConstraint.activate([ backgroundView.topAnchor.constraint(equalTo: view.topAnchor), backgroundView.bottomAnchor.constraint(equalTo: view.bottomAnchor), backgroundView.leadingAnchor.constraint(equalTo: view.leadingAnchor), backgroundView.trailingAnchor.constraint(equalTo: view.trailingAnchor) ]) let tap = UITapGestureRecognizer(target: self, action: #selector(dismissViewer)) backgroundView.addGestureRecognizer(tap) } private func setupScrollView() { scrollView.translatesAutoresizingMaskIntoConstraints = false scrollView.delegate = self scrollView.minimumZoomScale = 1.0 scrollView.maximumZoomScale = 5.0 scrollView.showsVerticalScrollIndicator = false scrollView.showsHorizontalScrollIndicator = false scrollView.alwaysBounceVertical = false scrollView.alwaysBounceHorizontal = false scrollView.decelerationRate = .fast view.addSubview(scrollView) NSLayoutConstraint.activate([ scrollView.topAnchor.constraint(equalTo: view.topAnchor), scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor), scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor) ]) let swipeDown = UISwipeGestureRecognizer(target: self, action: #selector(dismissViewer)) swipeDown.direction = .down scrollView.addGestureRecognizer(swipeDown) } private func setupImageView() { imageView.image = configuration.image imageView.contentMode = .scaleAspectFit imageView.isUserInteractionEnabled = true imageView.accessibilityLabel = configuration.altText imageView.isAccessibilityElement = configuration.altText != nil scrollView.addSubview(imageView) let doubleTap = UITapGestureRecognizer(target: self, action: #selector(handleDoubleTap(_:))) doubleTap.numberOfTapsRequired = 2 imageView.addGestureRecognizer(doubleTap) let singleTap = UITapGestureRecognizer(target: self, action: #selector(toggleChrome)) singleTap.numberOfTapsRequired = 1 singleTap.require(toFail: doubleTap) imageView.addGestureRecognizer(singleTap) } private func setupCloseButton() { let config = UIImage.SymbolConfiguration(pointSize: 16, weight: .semibold) closeButton.setImage(UIImage(systemName: "xmark", withConfiguration: config), for: .normal) closeButton.tintColor = .white closeButton.backgroundColor = UIColor(white: 0.3, alpha: 0.6) closeButton.layer.cornerRadius = 16 closeButton.clipsToBounds = true closeButton.addTarget(self, action: #selector(dismissViewer), for: .touchUpInside) closeButton.accessibilityLabel = NSLocalizedString("Close image viewer", comment: "Accessibility label for close button in image viewer") view.addSubview(closeButton) closeButton.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ closeButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 12), closeButton.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -16), closeButton.widthAnchor.constraint(equalToConstant: 32), closeButton.heightAnchor.constraint(equalToConstant: 32) ]) } // MARK: - Layout private func centerImage() { guard let image = imageView.image else { return } let boundsSize = scrollView.bounds.size guard boundsSize.width > 0, boundsSize.height > 0 else { return } let imageSize = image.size guard imageSize.width > 0, imageSize.height > 0 else { return } let widthRatio = boundsSize.width / imageSize.width let heightRatio = boundsSize.height / imageSize.height let fitScale = min(widthRatio, heightRatio) let fitWidth = imageSize.width * fitScale let fitHeight = imageSize.height * fitScale imageView.frame = CGRect( x: 0, y: 0, width: fitWidth, height: fitHeight ) scrollView.contentSize = imageView.frame.size let horizontalInset = max(0, (boundsSize.width - fitWidth) / 2) let verticalInset = max(0, (boundsSize.height - fitHeight) / 2) scrollView.contentInset = UIEdgeInsets( top: verticalInset, left: horizontalInset, bottom: verticalInset, right: horizontalInset ) } // MARK: - Actions @objc private func handleDoubleTap(_ gesture: UITapGestureRecognizer) { if scrollView.zoomScale > scrollView.minimumZoomScale { scrollView.setZoomScale(scrollView.minimumZoomScale, animated: true) } else { let point = gesture.location(in: imageView) let zoomSize = CGSize(width: 100, height: 100) let zoomRect = CGRect( x: point.x - zoomSize.width / 2, y: point.y - zoomSize.height / 2, width: zoomSize.width, height: zoomSize.height ) scrollView.zoom(to: zoomRect, animated: true) } } @objc private func toggleChrome() { isShowingChrome.toggle() UIView.animate(withDuration: 0.25) { self.closeButton.alpha = self.isShowingChrome ? 1.0 : 0.0 } } @objc private func dismissViewer() { dismiss(animated: true) } } // MARK: - UIScrollViewDelegate extension RDEPUBImageViewController: UIScrollViewDelegate { func viewForZooming(in scrollView: UIScrollView) -> UIView? { imageView } func scrollViewDidZoom(_ scrollView: UIScrollView) { guard let image = imageView.image else { return } let boundsSize = scrollView.bounds.size let imageSize = image.size guard imageSize.width > 0, imageSize.height > 0 else { return } let widthRatio = boundsSize.width / imageSize.width let heightRatio = boundsSize.height / imageSize.height let fitScale = min(widthRatio, heightRatio) let fitWidth = imageSize.width * fitScale * scrollView.zoomScale let fitHeight = imageSize.height * fitScale * scrollView.zoomScale imageView.frame = CGRect( x: 0, y: 0, width: fitWidth, height: fitHeight ) let horizontalInset = max(0, (boundsSize.width - fitWidth) / 2) let verticalInset = max(0, (boundsSize.height - fitHeight) / 2) scrollView.contentInset = UIEdgeInsets( top: verticalInset, left: horizontalInset, bottom: verticalInset, right: horizontalInset ) } }