// // LNImagePreviewCell.swift // Lanu // // Created by OneeChan on 2025/12/11. // import Foundation import UIKit import SnapKit protocol LNImagePreviewCellDelegate: AnyObject { func onImagePreviewCellDragToDismiss(cell: LNImagePreviewCell) } class LNImagePreviewCell: UICollectionViewCell { private let scrollView = UIScrollView() private let imageView = UIImageView() private let indicator = UIActivityIndicatorView(style: .large) private var panGesture: UIPanGestureRecognizer? private var touchBeginPoint: CGPoint = .zero private var lastMove: CGPoint = .zero private let dragScaleMin = 0.4 private let dragScaleOffsetY = UIScreen.main.bounds.height * 0.5 private let dragAlphaOffsetY = 150.0 private var curUrl: String? weak var delegate: LNImagePreviewCellDelegate? override init(frame: CGRect) { super.init(frame: frame) setupViews() setupGesture() } func update(url: String) { resetZoom() loadImage(from: url) } func resetZoom() { scrollView.setZoomScale(1.0, animated: false) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } extension LNImagePreviewCell { private func loadImage(from url: String) { curUrl = url let handler: (UIImage?) -> Void = { [weak self] image in guard let self else { return } guard curUrl == url else { return } indicator.stopAnimating() if let image, let viewController { if image.size.width / image.size.height > viewController.view.bounds.width / viewController.view.bounds.height { imageView.snp.remakeConstraints { make in make.edges.equalToSuperview() make.width.equalToSuperview() make.height.equalTo(self.imageView.snp.width).multipliedBy(image.size.height / image.size.width) } } else { imageView.snp.remakeConstraints { make in make.edges.equalToSuperview() make.height.equalToSuperview() make.width.equalTo(self.imageView.snp.height).multipliedBy(image.size.width / image.size.height) } } } else { imageView.snp.remakeConstraints { make in make.edges.equalToSuperview() make.width.height.equalTo(0) } } contentView.layoutIfNeeded() centerImage() } indicator.startAnimating() if url.hasPrefix("http") { imageView.sd_setImage(with: URL(string: url)) { image, _,_,_ in handler(image) } } else { imageView.sd_setImage(with: URL(fileURLWithPath: url)) { image,_,_,_ in handler(image) } } } private func centerImage() { let imageViewSize = imageView.frame.size let scrollViewSize = scrollView.bounds.size let verticalInset = (scrollViewSize.height - imageViewSize.height) / 2 let horizontalInset = (scrollViewSize.width - imageViewSize.width) / 2 scrollView.contentInset = UIEdgeInsets( top: max(verticalInset, 0), left: max(horizontalInset, 0), bottom: max(verticalInset, 0), right: max(horizontalInset, 0) ) } private func zoomRect(for scale: CGFloat, center: CGPoint) -> CGRect { var zoomRect = CGRect.zero zoomRect.size.height = imageView.frame.size.height / scale zoomRect.size.width = imageView.frame.size.width / scale zoomRect.origin.x = center.x - (zoomRect.size.width / 2) zoomRect.origin.y = center.y - (zoomRect.size.height / 2) return zoomRect } @objc private func handleDoubleTap(_ gesture: UITapGestureRecognizer) { let currentScale = scrollView.zoomScale let targetScale = currentScale == 1.0 ? scrollView.maximumZoomScale : 1.0 // 获取点击位置,缩放到点击点 let point = gesture.location(in: imageView) let zoomRect = zoomRect(for: targetScale, center: point) scrollView.zoom(to: zoomRect, animated: true) } @objc private func handlePan(_ gesture: UIPanGestureRecognizer) { let position = gesture.translation(in: self) switch gesture.state { case .began: touchBeginPoint = position break case .changed: lastMove = gesture.velocity(in: self) var frame = contentView.frame frame.origin.y = position.y - touchBeginPoint.y frame.origin.x = position.x - touchBeginPoint.x contentView.frame = frame let progress = (frame.origin.y / dragAlphaOffsetY).bounded(min: 0, max: 1.0) superview?.backgroundColor = .black.withAlphaComponent(1 - progress) let scale = 1 - (frame.origin.y / dragScaleOffsetY).bounded(min: 0, max: 1.0) * (1 - dragScaleMin) imageView.transform = .init(scaleX: scale, y: scale) break default: if lastMove.y > 0 { var frame = contentView.frame frame.origin.y = bounds.height UIView.animate(withDuration: 0.25) { [weak self] in guard let self else { return } contentView.frame = frame superview?.backgroundColor = .clear } completion: { [weak self] _ in guard let self else { return } delegate?.onImagePreviewCellDragToDismiss(cell: self) } } else { UIView.animate(withDuration: 0.25) { [weak self] in guard let self else { return } contentView.frame = bounds superview?.backgroundColor = .black imageView.transform = .identity } } break } } } extension LNImagePreviewCell: UIScrollViewDelegate { func viewForZooming(in scrollView: UIScrollView) -> UIView? { return imageView } func scrollViewDidZoom(_ scrollView: UIScrollView) { centerImage() panGesture?.isEnabled = scrollView.zoomScale == 1.0 } } extension LNImagePreviewCell: UIGestureRecognizerDelegate { override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { guard let pan = gestureRecognizer as? UIPanGestureRecognizer else { return true } let velocity = pan.velocity(in: self) return abs(velocity.y) > abs(velocity.x) } } extension LNImagePreviewCell { private func setupViews() { scrollView.delegate = self scrollView.minimumZoomScale = 1.0 scrollView.maximumZoomScale = UIScreen.main.bounds.height / UIScreen.main.bounds.width scrollView.showsVerticalScrollIndicator = false scrollView.showsHorizontalScrollIndicator = false contentView.addSubview(scrollView) scrollView.snp.makeConstraints { make in make.edges.equalToSuperview() } imageView.contentMode = .scaleAspectFit imageView.backgroundColor = .clear scrollView.addSubview(imageView) imageView.snp.makeConstraints { make in make.edges.equalToSuperview() make.width.height.equalTo(0) } indicator.color = .white indicator.hidesWhenStopped = true contentView.addSubview(indicator) indicator.snp.makeConstraints { make in make.center.equalToSuperview() } } private func setupGesture() { let doubleTap = UITapGestureRecognizer(target: self, action: #selector(handleDoubleTap)) doubleTap.numberOfTapsRequired = 2 scrollView.addGestureRecognizer(doubleTap) let pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:))) pan.delegate = self pan.isEnabled = true pan.maximumNumberOfTouches = 1 contentView.addGestureRecognizer(pan) panGesture = pan contentView.onTap { [weak self] in guard let self else { return } var frame = contentView.frame frame.origin.y = bounds.height UIView.animate(withDuration: 0.25) { [weak self] in guard let self else { return } contentView.frame = frame superview?.backgroundColor = .clear } completion: { [weak self] _ in guard let self else { return } delegate?.onImagePreviewCellDragToDismiss(cell: self) } } } }