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