ReadViewSDK/Sources/RDEpubReaderView/EPUBUI/RDEPUBImageViewController.swift
shenlei d7fcda345d refactor: rename RDReaderView -> RDEpubReaderView, update pod config and docs
- 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
2026-07-10 19:44:53 +09:00

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
)
}
}