315 lines
11 KiB
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))
|
|
}
|
|
}
|
|
}
|