jsdw_ios/QuickLocation/Component/ImageCrop/ImageCropView.swift

315 lines
11 KiB
Swift

//
// ImageCropView.swift
// QuickLocation
//
import UIKit
final class ImageCropView: UIView, UIScrollViewDelegate {
var onCancel: (() -> Void)?
var onConfirm: (() -> Void)?
private let scrollView = UIScrollView()
private let imageView = UIImageView()
private let overlayView = ImageCropOverlayView()
private let topBar = UIView()
private let bottomBar = UIView()
private var cropRect: CGRect = .zero
private var fittedCropRect: CGRect = .zero
private var pendingFit = false
private var userAdjusted = false
private var isFitting = false
private let bottomBarHeight: CGFloat = 56
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .black
setupUI()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setImage(_ image: UIImage) {
imageView.image = image.fixOrientation()
pendingFit = true
setNeedsLayout()
}
func rotateClockwise() {
guard let image = imageView.image else { return }
setImage(image.rotatedClockwise())
}
func croppedImage() -> UIImage? {
guard let image = imageView.image, imageView.bounds.width > 0, cropRect.width > 0 else {
return nil
}
let zoom = scrollView.zoomScale
guard zoom > 0 else { return nil }
let origin = CGPoint(
x: (scrollView.contentOffset.x + cropRect.minX) / zoom,
y: (scrollView.contentOffset.y + cropRect.minY) / zoom
)
let size = CGSize(
width: cropRect.width / zoom,
height: cropRect.height / zoom
)
let rect = CGRect(origin: origin, size: size)
guard let cgImage = image.cgImage, imageView.bounds.width > 0, imageView.bounds.height > 0 else {
return nil
}
let scaleX = CGFloat(cgImage.width) / imageView.bounds.width
let scaleY = CGFloat(cgImage.height) / imageView.bounds.height
var pixelRect = CGRect(
x: rect.origin.x * scaleX,
y: rect.origin.y * scaleY,
width: rect.width * scaleX,
height: rect.height * scaleY
).integral
let bounds = CGRect(x: 0, y: 0, width: cgImage.width, height: cgImage.height)
pixelRect = pixelRect.intersection(bounds)
guard pixelRect.width >= 1, pixelRect.height >= 1,
let cropped = cgImage.cropping(to: pixelRect) else {
return nil
}
return UIImage(cgImage: cropped, scale: 1, orientation: .up)
}
override func layoutSubviews() {
super.layoutSubviews()
scrollView.frame = bounds
overlayView.frame = bounds
let topSafe = safeAreaInsets.top
let bottomSafe = safeAreaInsets.bottom
topBar.frame = CGRect(x: 0, y: 0, width: bounds.width, height: topSafe + 44)
bottomBar.frame = CGRect(
x: 0,
y: bounds.height - bottomSafe - bottomBarHeight,
width: bounds.width,
height: bottomSafe + bottomBarHeight
)
let side = bounds.width
let availableTop = topBar.frame.maxY
let availableBottom = bottomBar.frame.minY
let originY = availableTop + max(0, (availableBottom - availableTop - side) / 2)
cropRect = CGRect(x: 0, y: originY, width: side, height: side)
overlayView.cropRect = cropRect
applyCropInsets()
if pendingFit, bounds.width > 0, imageView.image != nil {
pendingFit = false
userAdjusted = false
fitImageToCrop()
} else if !userAdjusted, cropRect != fittedCropRect, imageView.image != nil {
fitImageToCrop()
}
}
// MARK: - UIScrollViewDelegate
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
imageView
}
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
if !isFitting { userAdjusted = true }
}
func scrollViewWillBeginZooming(_ scrollView: UIScrollView, with view: UIView?) {
if !isFitting { userAdjusted = true }
}
// MARK: - Private
private func setupUI() {
scrollView.delegate = self
scrollView.showsHorizontalScrollIndicator = false
scrollView.showsVerticalScrollIndicator = false
scrollView.bounces = true
scrollView.alwaysBounceVertical = true
scrollView.alwaysBounceHorizontal = true
scrollView.bouncesZoom = true
scrollView.contentInsetAdjustmentBehavior = .never
scrollView.backgroundColor = .black
addSubview(scrollView)
imageView.contentMode = .scaleToFill
imageView.isUserInteractionEnabled = true
scrollView.addSubview(imageView)
overlayView.isUserInteractionEnabled = false
addSubview(overlayView)
addSubview(topBar)
addSubview(bottomBar)
let closeBtn = UIButton(type: .custom)
closeBtn.setImage(UIImage(named: "PhotoPicker/btn_close"), for: .normal)
closeBtn.addTarget(self, action: #selector(tapCancel), for: .touchUpInside)
topBar.addSubview(closeBtn)
closeBtn.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
closeBtn.leadingAnchor.constraint(equalTo: topBar.leadingAnchor, constant: 8),
closeBtn.bottomAnchor.constraint(equalTo: topBar.bottomAnchor),
closeBtn.widthAnchor.constraint(equalToConstant: 44),
closeBtn.heightAnchor.constraint(equalToConstant: 44)
])
let cancelBtn = UIButton(type: .custom)
cancelBtn.setTitle("取消", for: .normal)
cancelBtn.setTitleColor(.white, for: .normal)
cancelBtn.titleLabel?.font = .systemFont(ofSize: 16)
cancelBtn.addTarget(self, action: #selector(tapCancel), for: .touchUpInside)
bottomBar.addSubview(cancelBtn)
let rotateBtn = UIButton(type: .custom)
let rotateConfig = UIImage.SymbolConfiguration(pointSize: 20, weight: .regular)
rotateBtn.setImage(UIImage(systemName: "rotate.right", withConfiguration: rotateConfig), for: .normal)
rotateBtn.tintColor = .white
rotateBtn.addTarget(self, action: #selector(tapRotate), for: .touchUpInside)
bottomBar.addSubview(rotateBtn)
let confirmBtn = UIButton(type: .custom)
confirmBtn.setTitle("确定", for: .normal)
confirmBtn.setTitleColor(.white, for: .normal)
confirmBtn.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
confirmBtn.backgroundColor = UIColor(hexStr: "#07C160")
confirmBtn.layer.cornerRadius = 8
confirmBtn.addTarget(self, action: #selector(tapConfirm), for: .touchUpInside)
bottomBar.addSubview(confirmBtn)
cancelBtn.translatesAutoresizingMaskIntoConstraints = false
rotateBtn.translatesAutoresizingMaskIntoConstraints = false
confirmBtn.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
cancelBtn.leadingAnchor.constraint(equalTo: bottomBar.leadingAnchor, constant: 20),
cancelBtn.topAnchor.constraint(equalTo: bottomBar.topAnchor, constant: 8),
cancelBtn.heightAnchor.constraint(equalToConstant: 40),
rotateBtn.centerXAnchor.constraint(equalTo: bottomBar.centerXAnchor),
rotateBtn.centerYAnchor.constraint(equalTo: cancelBtn.centerYAnchor),
rotateBtn.widthAnchor.constraint(equalToConstant: 44),
rotateBtn.heightAnchor.constraint(equalToConstant: 44),
confirmBtn.trailingAnchor.constraint(equalTo: bottomBar.trailingAnchor, constant: -16),
confirmBtn.centerYAnchor.constraint(equalTo: cancelBtn.centerYAnchor),
confirmBtn.widthAnchor.constraint(equalToConstant: 72),
confirmBtn.heightAnchor.constraint(equalToConstant: 36)
])
}
private func fitImageToCrop() {
guard let image = imageView.image, cropRect.width > 0 else { return }
let imageSize = image.size
guard imageSize.width > 0, imageSize.height > 0 else { return }
isFitting = true
scrollView.minimumZoomScale = 1
scrollView.maximumZoomScale = 1
scrollView.zoomScale = 1
imageView.transform = .identity
imageView.frame = CGRect(origin: .zero, size: imageSize)
scrollView.contentSize = imageSize
applyCropInsets()
let minScale = max(cropRect.width / imageSize.width, cropRect.height / imageSize.height)
scrollView.minimumZoomScale = minScale
scrollView.maximumZoomScale = max(minScale * 4, 4)
scrollView.zoomScale = minScale
let scaled = CGSize(width: imageSize.width * minScale, height: imageSize.height * minScale)
scrollView.contentOffset = CGPoint(
x: (scaled.width - cropRect.width) / 2 - scrollView.contentInset.left,
y: (scaled.height - cropRect.height) / 2 - scrollView.contentInset.top
)
fittedCropRect = cropRect
isFitting = false
}
private func applyCropInsets() {
scrollView.contentInset = UIEdgeInsets(
top: cropRect.minY,
left: cropRect.minX,
bottom: max(0, bounds.height - cropRect.maxY),
right: max(0, bounds.width - cropRect.maxX)
)
}
@objc private func tapCancel() {
onCancel?()
}
@objc private func tapRotate() {
rotateClockwise()
}
@objc private func tapConfirm() {
onConfirm?()
}
}
private final class ImageCropOverlayView: UIView {
var cropRect: CGRect = .zero {
didSet { updateMaskPath() }
}
private let dimLayer: CAShapeLayer = {
let layer = CAShapeLayer()
layer.fillRule = .evenOdd
layer.fillColor = UIColor.black.withAlphaComponent(0.55).cgColor
return layer
}()
override init(frame: CGRect) {
super.init(frame: frame)
isUserInteractionEnabled = false
backgroundColor = .clear
layer.addSublayer(dimLayer)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
dimLayer.frame = bounds
updateMaskPath()
}
private func updateMaskPath() {
let path = UIBezierPath(rect: bounds)
if cropRect.width > 0, cropRect.height > 0 {
path.append(UIBezierPath(rect: cropRect))
}
dimLayer.path = path.cgPath
}
}
private extension UIImage {
func rotatedClockwise() -> UIImage {
let newSize = CGSize(width: size.height, height: size.width)
let format = UIGraphicsImageRendererFormat.default()
format.scale = scale
format.opaque = false
let renderer = UIGraphicsImageRenderer(size: newSize, format: format)
return renderer.image { ctx in
ctx.cgContext.translateBy(x: newSize.width, y: 0)
ctx.cgContext.rotate(by: .pi / 2)
draw(in: CGRect(origin: .zero, size: size))
}
}
}