- Rename source module from RDReaderView to RDEpubReaderView - Move all source files from Sources/RDReaderView/ to Sources/RDEpubReaderView/ - Update podspec: RDReaderView.podspec -> RDEpubReaderView.podspec - Update Podfile, demo project, and CocoaPods config for new pod name - Delete old RDReaderView pod support files from ReadViewDemo/Pods - Add new RDEpubReaderView pod support files - Update documentation (API ref, architecture, UML, conventions, etc.) - Add FixedLayoutRotationTests - Update .gitignore: exclude .DS_Store, manual unpack backups, _ssoft-output
241 lines
8.5 KiB
Swift
241 lines
8.5 KiB
Swift
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
|
|
)
|
|
}
|
|
} |