410 lines
16 KiB
Swift
410 lines
16 KiB
Swift
|
|
//
|
||
|
|
// BrowseImageZoomView.swift
|
||
|
|
// Crush
|
||
|
|
//
|
||
|
|
// Created by Leon on 2025/7/26.
|
||
|
|
//
|
||
|
|
|
||
|
|
import SnapKit
|
||
|
|
import UIKit
|
||
|
|
|
||
|
|
protocol BrowseImageZoomViewDelegate: AnyObject {
|
||
|
|
func dismisAnimation(_ zoomImageView: UIImageView, toFrame: CGRect)
|
||
|
|
func singleTapZoomView(_ zoomImageView: UIImageView) -> Bool
|
||
|
|
func longPressZoomView(_ zoomView: BrowseImageZoomView) -> Bool
|
||
|
|
func setVCViewBackgroundColor(_ alpha: CGFloat)
|
||
|
|
}
|
||
|
|
|
||
|
|
class BrowseImageZoomView: UIView, UIScrollViewDelegate, UIGestureRecognizerDelegate {
|
||
|
|
private let kMaxScale: CGFloat = 3.0
|
||
|
|
private let kMaxDropHeight: CGFloat = 60
|
||
|
|
|
||
|
|
private var imageUrl: String?
|
||
|
|
private var progress: PhotoBrowseProgressView?
|
||
|
|
private var noticeButton: UIButton?
|
||
|
|
private var stateLabel: UILabel?
|
||
|
|
private var origFrame: CGRect = .zero
|
||
|
|
|
||
|
|
var scrollView: UIScrollView!
|
||
|
|
var imageView: UIImageView!
|
||
|
|
weak var delegate: BrowseImageZoomViewDelegate?
|
||
|
|
var displayIndex: Int = 0
|
||
|
|
var imageModel: PhotoBrowserModel?
|
||
|
|
|
||
|
|
init(imageModel: PhotoBrowserModel) {
|
||
|
|
super.init(frame: .zero)
|
||
|
|
self.imageModel = imageModel
|
||
|
|
setupViews()
|
||
|
|
}
|
||
|
|
|
||
|
|
required init?(coder: NSCoder) {
|
||
|
|
fatalError("init(coder:) has not been implemented")
|
||
|
|
}
|
||
|
|
|
||
|
|
private func setupViews() {
|
||
|
|
backgroundColor = .clear
|
||
|
|
|
||
|
|
scrollView = UIScrollView()
|
||
|
|
scrollView.backgroundColor = UIColor.black.withAlphaComponent(1.0) // Assuming kBackgroundColor(1) is black
|
||
|
|
scrollView.clipsToBounds = true
|
||
|
|
scrollView.showsVerticalScrollIndicator = false
|
||
|
|
scrollView.showsHorizontalScrollIndicator = false
|
||
|
|
scrollView.delegate = self
|
||
|
|
if #available(iOS 11.0, *) {
|
||
|
|
scrollView.contentInsetAdjustmentBehavior = .never
|
||
|
|
}
|
||
|
|
addSubview(scrollView)
|
||
|
|
|
||
|
|
// Using SnapKit for scrollView layout
|
||
|
|
scrollView.snp.makeConstraints { make in
|
||
|
|
make.edges.equalToSuperview()
|
||
|
|
}
|
||
|
|
|
||
|
|
imageView = UIImageView(frame: CGRect(
|
||
|
|
x: 0,
|
||
|
|
y: (UIScreen.main.bounds.height - UIScreen.main.bounds.width) / 2,
|
||
|
|
width: UIScreen.main.bounds.width,
|
||
|
|
height: UIScreen.main.bounds.width
|
||
|
|
))
|
||
|
|
imageView.backgroundColor = .clear
|
||
|
|
imageView.image = imageModel?.placeHolder
|
||
|
|
scrollView.addSubview(imageView)
|
||
|
|
|
||
|
|
addGestures()
|
||
|
|
reloadImage(imageModel)
|
||
|
|
}
|
||
|
|
|
||
|
|
private func addGestures() {
|
||
|
|
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(singleGestureAction(_:)))
|
||
|
|
addGestureRecognizer(tapGesture)
|
||
|
|
|
||
|
|
let doubleGesture = UITapGestureRecognizer(target: self, action: #selector(doubleGestureAction(_:)))
|
||
|
|
doubleGesture.numberOfTapsRequired = 2
|
||
|
|
addGestureRecognizer(doubleGesture)
|
||
|
|
tapGesture.require(toFail: doubleGesture)
|
||
|
|
|
||
|
|
let longPressGesture = UILongPressGestureRecognizer(target: self, action: #selector(longPressGestureAction(_:)))
|
||
|
|
addGestureRecognizer(longPressGesture)
|
||
|
|
|
||
|
|
let panGesture = UIPanGestureRecognizer(target: self, action: #selector(panGestureAction(_:)))
|
||
|
|
panGesture.delegate = self
|
||
|
|
addGestureRecognizer(panGesture)
|
||
|
|
}
|
||
|
|
|
||
|
|
private func resetErrorNoticeView() {
|
||
|
|
stateLabel?.removeFromSuperview()
|
||
|
|
stateLabel = nil
|
||
|
|
noticeButton?.removeFromSuperview()
|
||
|
|
noticeButton = nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func reloadImage(_ imageModel: PhotoBrowserModel?) {
|
||
|
|
guard let imageModel = imageModel else { return }
|
||
|
|
self.imageModel = imageModel
|
||
|
|
|
||
|
|
if let image = imageModel.image {
|
||
|
|
imageView.image = image
|
||
|
|
updateImageViewFrame()
|
||
|
|
}
|
||
|
|
if let url = imageModel.imageUrl {
|
||
|
|
loadImageWithImageUrl(url, animated: imageModel.sourceRect.size.width > 0)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private func loadImageWithImageUrl(_ imageUrl: String, animated:Bool = true) {
|
||
|
|
self.imageUrl = imageUrl
|
||
|
|
// guard let url = URL(string: imageUrl) else { return }
|
||
|
|
// if let cacheImage = SDImageCache.shared.imageFromDiskCache(forKey: url.absoluteString) {
|
||
|
|
// imageView.image = cacheImage
|
||
|
|
// resetErrorNoticeView()
|
||
|
|
// setNeedsLayout()
|
||
|
|
// imageView.setNeedsDisplay()
|
||
|
|
// updateImageViewFrame()
|
||
|
|
// return
|
||
|
|
// }
|
||
|
|
|
||
|
|
progress = PhotoBrowseProgressView()
|
||
|
|
progress?.isHidden = false
|
||
|
|
addSubview(progress!)
|
||
|
|
progress?.snp.makeConstraints { make in
|
||
|
|
make.size.equalTo(CGSize(width: 50, height: 50))
|
||
|
|
make.center.equalToSuperview()
|
||
|
|
}
|
||
|
|
|
||
|
|
imageView.loadImage(imageUrl) { receivedSize, totalSize in
|
||
|
|
guard self.imageUrl == imageUrl, totalSize > 0 else { return } // let self = self,
|
||
|
|
DispatchQueue.main.async {
|
||
|
|
self.progress?.progress = CGFloat(receivedSize) / CGFloat(totalSize)
|
||
|
|
}
|
||
|
|
} completionBlock: { result in
|
||
|
|
guard self.imageUrl == imageUrl else { return } // let self = self,
|
||
|
|
|
||
|
|
DispatchQueue.main.async {
|
||
|
|
self.progress?.removeFromSuperview()
|
||
|
|
self.progress = nil
|
||
|
|
|
||
|
|
switch result {
|
||
|
|
case let .success(imageResult):
|
||
|
|
let duration = animated ? 0.25 : 0
|
||
|
|
UIView.animate(withDuration: duration) {
|
||
|
|
self.imageView.image = imageResult.image
|
||
|
|
self.resetErrorNoticeView()
|
||
|
|
self.setNeedsLayout()
|
||
|
|
self.imageView.setNeedsDisplay()
|
||
|
|
self.updateImageViewFrame()
|
||
|
|
}
|
||
|
|
case let .failure(error):
|
||
|
|
dlog("图片加载失败: \(error.localizedDescription)")
|
||
|
|
let button = UIButton()
|
||
|
|
button.isUserInteractionEnabled = false
|
||
|
|
button.setImage(UIImage(named: "icon_notice_white"), for: .normal)
|
||
|
|
self.addSubview(button)
|
||
|
|
self.noticeButton = button
|
||
|
|
button.snp.makeConstraints { make in
|
||
|
|
make.size.equalTo(CGSize(width: 16, height: 16))
|
||
|
|
make.centerX.equalToSuperview()
|
||
|
|
make.centerY.equalToSuperview().offset(-36)
|
||
|
|
}
|
||
|
|
|
||
|
|
let errorLabel = UILabel()
|
||
|
|
errorLabel.backgroundColor = .clear
|
||
|
|
errorLabel.textColor = .white
|
||
|
|
errorLabel.textAlignment = .center
|
||
|
|
errorLabel.clipsToBounds = true
|
||
|
|
errorLabel.text = NSLocalizedString("image_load_failed", comment: "")
|
||
|
|
errorLabel.font = .systemFont(ofSize: 14)
|
||
|
|
errorLabel.numberOfLines = 2
|
||
|
|
errorLabel.sizeToFit()
|
||
|
|
self.addSubview(errorLabel)
|
||
|
|
errorLabel.snp.makeConstraints { make in
|
||
|
|
make.size.equalTo(CGSize(width: UIScreen.main.bounds.width - 80, height: 30))
|
||
|
|
make.center.equalToSuperview()
|
||
|
|
}
|
||
|
|
self.stateLabel = errorLabel
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
/*
|
||
|
|
imageView.sd_setImage(with: url, placeholderImage: imageModel?.placeHolder, options: [.retryFailed, .lowPriority, .handleCookies]) { [weak self] receivedSize, expectedSize, _ in
|
||
|
|
guard let self = self, self.imageUrl == imageUrl, expectedSize > 0 else { return }
|
||
|
|
DispatchQueue.main.async {
|
||
|
|
self.progress?.progress = CGFloat(receivedSize) / CGFloat(expectedSize)
|
||
|
|
}
|
||
|
|
} completed: { [weak self] image, error, _, _ in
|
||
|
|
guard let self = self, self.imageUrl == imageUrl else { return }
|
||
|
|
self.progress?.removeFromSuperview()
|
||
|
|
self.progress = nil
|
||
|
|
|
||
|
|
if let error = error {
|
||
|
|
let button = UIButton()
|
||
|
|
button.isUserInteractionEnabled = false
|
||
|
|
button.setImage(UIImage(named: "icon_notice_white"), for: .normal)
|
||
|
|
self.addSubview(button)
|
||
|
|
self.noticeButton = button
|
||
|
|
button.snp.makeConstraints { make in
|
||
|
|
make.size.equalTo(CGSize(width: 16, height: 16))
|
||
|
|
make.centerX.equalToSuperview()
|
||
|
|
make.centerY.equalToSuperview().offset(-36)
|
||
|
|
}
|
||
|
|
|
||
|
|
let errorLabel = UILabel()
|
||
|
|
errorLabel.backgroundColor = .clear
|
||
|
|
errorLabel.textColor = .white
|
||
|
|
errorLabel.textAlignment = .center
|
||
|
|
errorLabel.clipsToBounds = true
|
||
|
|
errorLabel.text = NSLocalizedString("image_load_failed", comment: "")
|
||
|
|
errorLabel.font = .systemFont(ofSize: 14)
|
||
|
|
errorLabel.numberOfLines = 2
|
||
|
|
errorLabel.sizeToFit()
|
||
|
|
errorLabel.snp.makeConstraints { make in
|
||
|
|
make.size.equalTo(CGSize(width: UIScreen.main.bounds.width - 80, height: 30))
|
||
|
|
make.center.equalToSuperview()
|
||
|
|
}
|
||
|
|
self.addSubview(errorLabel)
|
||
|
|
self.stateLabel = errorLabel
|
||
|
|
} else {
|
||
|
|
UIView.animate(withDuration: 0.25) {
|
||
|
|
self.imageView.image = image
|
||
|
|
self.resetErrorNoticeView()
|
||
|
|
self.setNeedsLayout()
|
||
|
|
self.imageView.setNeedsDisplay()
|
||
|
|
self.updateImageViewFrame()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}*/
|
||
|
|
}
|
||
|
|
|
||
|
|
private func updateImageViewFrame() {
|
||
|
|
guard let image = imageView.image, image.size.height > 0, image.size.width > 0 else { return }
|
||
|
|
|
||
|
|
let screenHeight = UIScreen.main.bounds.height
|
||
|
|
let imageRatio = image.size.width / image.size.height
|
||
|
|
let newWidth = frame.width
|
||
|
|
let newHeight = frame.width / imageRatio
|
||
|
|
let newX: CGFloat = 0
|
||
|
|
let newY = newHeight > screenHeight ? 0 : (screenHeight - newHeight) / 2
|
||
|
|
|
||
|
|
let newRect = CGRect(x: newX, y: newY, width: newWidth, height: newHeight)
|
||
|
|
imageView.frame = newRect
|
||
|
|
origFrame = newRect
|
||
|
|
|
||
|
|
scrollView.contentSize = CGSize(width: newWidth, height: max(newHeight, screenHeight))
|
||
|
|
scrollView.minimumZoomScale = 1.0
|
||
|
|
scrollView.maximumZoomScale = max(screenHeight / newHeight, kMaxScale)
|
||
|
|
scrollView.zoomScale = 1.0
|
||
|
|
}
|
||
|
|
|
||
|
|
@objc private func panGestureAction(_ recognizer: UIPanGestureRecognizer) {
|
||
|
|
switch recognizer.state {
|
||
|
|
case .ended:
|
||
|
|
scrollView.isScrollEnabled = true
|
||
|
|
if imageView.frame.origin.y - origFrame.origin.y > kMaxDropHeight {
|
||
|
|
dismisAnimationWithGestureView(recognizer.view)
|
||
|
|
} else {
|
||
|
|
resetFrame()
|
||
|
|
}
|
||
|
|
case .cancelled:
|
||
|
|
resetFrame()
|
||
|
|
case .changed:
|
||
|
|
let point = recognizer.translation(in: recognizer.view)
|
||
|
|
updatePanFrame(point)
|
||
|
|
default:
|
||
|
|
break
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
@objc private func singleGestureAction(_ recognizer: UITapGestureRecognizer) {
|
||
|
|
if let stateLabel = stateLabel, !stateLabel.isHidden {
|
||
|
|
retryAction()
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if delegate?.singleTapZoomView(imageView) ?? false {
|
||
|
|
dismisAnimationWithGestureView(recognizer.view)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
@objc private func doubleGestureAction(_ recognizer: UITapGestureRecognizer) {
|
||
|
|
if scrollView.zoomScale > scrollView.minimumZoomScale + 0.5 {
|
||
|
|
scrollView.setZoomScale(scrollView.minimumZoomScale, animated: true)
|
||
|
|
} else {
|
||
|
|
let tapPoint = recognizer.location(in: recognizer.view)
|
||
|
|
scrollView.zoom(to: zoomRectForScale(2.0, withTapPoint: tapPoint), animated: true)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
@objc private func longPressGestureAction(_ recognizer: UILongPressGestureRecognizer) {
|
||
|
|
if recognizer.state == .began {
|
||
|
|
delegate?.longPressZoomView(self)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private func retryAction() {
|
||
|
|
resetErrorNoticeView()
|
||
|
|
|
||
|
|
if let imageUrl = imageUrl, let imageModel = imageModel {
|
||
|
|
loadImageWithImageUrl(imageUrl, animated: imageModel.sourceRect.size.width > 0)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private func updatePanFrame(_ point: CGPoint) {
|
||
|
|
var scale = 1.0
|
||
|
|
if point.y >= 0 && point.y < 360 {
|
||
|
|
scale = 1 - point.y / 410
|
||
|
|
scrollView.backgroundColor = UIColor.black.withAlphaComponent(1 - point.y / 250.0)
|
||
|
|
delegate?.setVCViewBackgroundColor(1 - point.y / 200.0)
|
||
|
|
} else if point.y > 360 {
|
||
|
|
scale = 1 - 360 / 410.0
|
||
|
|
}
|
||
|
|
|
||
|
|
let radiusWidth = imageView.frame.width - (imageModel?.sourceRect.width ?? 0)
|
||
|
|
let newWidth = radiusWidth > 10 ? origFrame.width * scale : imageView.frame.width
|
||
|
|
let newHeight = radiusWidth > 10 ? origFrame.height * scale : imageView.frame.height
|
||
|
|
let offsetX = origFrame.width - newWidth
|
||
|
|
|
||
|
|
imageView.frame = CGRect(
|
||
|
|
x: origFrame.origin.x + point.x + offsetX / 2.0,
|
||
|
|
y: origFrame.origin.y + point.y,
|
||
|
|
width: newWidth,
|
||
|
|
height: newHeight
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
private func resetFrame() {
|
||
|
|
isUserInteractionEnabled = false
|
||
|
|
UIView.animate(withDuration: 0.2) { [weak self] in
|
||
|
|
guard let self = self else { return }
|
||
|
|
self.imageView.frame = self.origFrame
|
||
|
|
self.scrollView.backgroundColor = UIColor.black.withAlphaComponent(1.0)
|
||
|
|
self.delegate?.setVCViewBackgroundColor(1.0)
|
||
|
|
} completion: { [weak self] _ in
|
||
|
|
self?.isUserInteractionEnabled = true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private func dismisAnimationWithGestureView(_ view: UIView?) {
|
||
|
|
delegate?.dismisAnimation(imageView, toFrame: imageModel?.sourceRect ?? .zero)
|
||
|
|
isHidden = true
|
||
|
|
}
|
||
|
|
|
||
|
|
private func zoomRectForScale(_ scale: CGFloat, withTapPoint point: CGPoint) -> CGRect {
|
||
|
|
let touchX = point.x / scrollView.zoomScale
|
||
|
|
let touchY = point.y / scrollView.zoomScale
|
||
|
|
let x = touchX + scrollView.contentOffset.x - frame.width / (2 * scale)
|
||
|
|
let y = touchY + scrollView.contentOffset.y - frame.height / (2 * scale)
|
||
|
|
return CGRect(x: x, y: y, width: frame.width / scale, height: frame.height / scale)
|
||
|
|
}
|
||
|
|
|
||
|
|
// MARK: - UIScrollViewDelegate
|
||
|
|
|
||
|
|
func scrollViewDidZoom(_ scrollView: UIScrollView) {
|
||
|
|
imageView.center = centerOfScrollViewContent(scrollView)
|
||
|
|
}
|
||
|
|
|
||
|
|
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
|
||
|
|
return imageView
|
||
|
|
}
|
||
|
|
|
||
|
|
func scrollViewWillBeginZooming(_ scrollView: UIScrollView, with view: UIView?) {
|
||
|
|
isUserInteractionEnabled = false
|
||
|
|
}
|
||
|
|
|
||
|
|
func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) {
|
||
|
|
isUserInteractionEnabled = true
|
||
|
|
}
|
||
|
|
|
||
|
|
private func centerOfScrollViewContent(_ scrollView: UIScrollView) -> CGPoint {
|
||
|
|
let offsetX = scrollView.bounds.width > scrollView.contentSize.width ? (scrollView.bounds.width - scrollView.contentSize.width) * 0.5 : 0
|
||
|
|
let offsetY = scrollView.bounds.height > scrollView.contentSize.height ? (scrollView.bounds.height - scrollView.contentSize.height) * 0.5 : 0
|
||
|
|
return CGPoint(x: scrollView.contentSize.width * 0.5 + offsetX, y: scrollView.contentSize.height * 0.5 + offsetY)
|
||
|
|
}
|
||
|
|
|
||
|
|
func prepareForReuse() {
|
||
|
|
displayIndex = 0
|
||
|
|
resetErrorNoticeView()
|
||
|
|
progress?.removeFromSuperview()
|
||
|
|
progress = nil
|
||
|
|
imageView.alpha = 1.0
|
||
|
|
imageView.image = nil
|
||
|
|
imageModel = nil
|
||
|
|
delegate = nil
|
||
|
|
updateImageViewFrame()
|
||
|
|
}
|
||
|
|
|
||
|
|
// MARK: - UIGestureRecognizerDelegate
|
||
|
|
|
||
|
|
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
|
||
|
|
return scrollView.isScrollEnabled
|
||
|
|
}
|
||
|
|
|
||
|
|
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
||
|
|
if let pan = gestureRecognizer as? UIPanGestureRecognizer {
|
||
|
|
let velocity = pan.velocity(in: pan.view)
|
||
|
|
if abs(velocity.x) < abs(velocity.y) && velocity.y > 0 && scrollView.contentOffset.y < 10 {
|
||
|
|
scrollView.isScrollEnabled = false
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
}
|