Created
August 12, 2026 06:35
-
-
Save AFutureD/dcd9763ec26c31a5396afe0877e1ea57 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // | |
| // PhotoEditView+CropOverlay.swift | |
| // FaceYoga | |
| // | |
| // Created by Huanan on 2025/8/19. | |
| // | |
| import MKKit13 | |
| // MARK: - CropOverlayView.CropRectangeLayer | |
| extension CropOverlayView { | |
| class CropRectangeLayer: CALayer { | |
| @NSManaged public var cropRect: CGRect | |
| var drawColor: UIColor { | |
| 0xFF36_B2A2.argb | |
| } | |
| let handleSize: CGFloat = 16 | |
| // 2. Tell Core Animation that a change to `cropRect` requires a redraw. | |
| override class func needsDisplay(forKey key: String) -> Bool { | |
| if key == #keyPath(cropRect) { | |
| return true | |
| } | |
| return super.needsDisplay(forKey: key) | |
| } | |
| override func draw(in ctx: CGContext) { | |
| let cropRect = cropRect // presentation()?.cropRect ?? cropRect | |
| // Draw the dashed crop rectangle | |
| ctx.setStrokeColor(drawColor.cgColor) | |
| ctx.setLineWidth(3) | |
| ctx.setLineDash(phase: 0, lengths: [6, 4]) | |
| ctx.stroke(cropRect) | |
| ctx.setLineDash(phase: 0, lengths: []) | |
| let points: [CGPoint] = [ | |
| CGPoint(x: cropRect.minX, y: cropRect.minY), | |
| CGPoint(x: cropRect.midX, y: cropRect.minY), | |
| CGPoint(x: cropRect.maxX, y: cropRect.minY), | |
| CGPoint(x: cropRect.maxX, y: cropRect.midY), | |
| CGPoint(x: cropRect.maxX, y: cropRect.maxY), | |
| CGPoint(x: cropRect.midX, y: cropRect.maxY), | |
| CGPoint(x: cropRect.minX, y: cropRect.maxY), | |
| CGPoint(x: cropRect.minX, y: cropRect.midY), | |
| ] | |
| ctx.setFillColor(UIColor.white.cgColor) | |
| ctx.setStrokeColor(drawColor.cgColor) | |
| ctx.setLineWidth(3) | |
| for point in points { | |
| let handleRect = CGRect( | |
| x: point.x - handleSize / 2, | |
| y: point.y - handleSize / 2, | |
| width: handleSize, | |
| height: handleSize | |
| ) | |
| ctx.fill(handleRect) | |
| ctx.stroke(handleRect) | |
| } | |
| } | |
| } | |
| } | |
| // MARK: - CropOverlayView | |
| class CropOverlayView: MKBaseView { | |
| lazy var dimmingLayer = { | |
| let shape = CAShapeLayer() | |
| shape.fillRule = .evenOdd | |
| return shape | |
| }() | |
| @objc lazy var cropRectangeLayer = { | |
| let box = CropRectangeLayer() | |
| box.contentsScale = UIScreen.main.scale | |
| return box | |
| }() | |
| let handleSize: CGFloat = 16 | |
| private var _cropRect: CGRect = .zero | |
| var cropRect: CGRect { | |
| get { | |
| _cropRect | |
| } | |
| set { | |
| setCropRect(newValue, dragging: true, animate: false) | |
| } | |
| } | |
| let draggingColor = UIColor.yellow | |
| let presentColor = 0xFF36_B2A2.argb | |
| var drawColor: UIColor { | |
| state.isAdjustRect ? draggingColor : presentColor | |
| } | |
| let insets = UIEdgeInsets(top: 20.rw, left: 20.rw, bottom: 20.rw, right: 20.rw) | |
| var boundingRect: CGRect { | |
| bounds.inset(by: insets) | |
| } | |
| enum State { | |
| case none | |
| case start(CGPoint, CGRect, UIRectEdge) // start point | |
| case changing(CGPoint, CGRect, UIRectEdge) | |
| var startPoint: CGPoint? { | |
| if case let .start(value, _, _) = self { | |
| return value | |
| } | |
| return nil | |
| } | |
| var isAdjustRect: Bool { | |
| if case .none = self { | |
| return false | |
| } | |
| return true | |
| } | |
| } | |
| @Published var state: State = .none { | |
| didSet { | |
| setNeedsLayout() | |
| } | |
| } | |
| var tolerance = 30.rw | |
| lazy var isAdjustRect = self.$state.map(\.isAdjustRect).removeDuplicates().eraseToAnyPublisher() | |
| var allowRect: CGRect = .infinite | |
| init() { | |
| super.init(frame: .zero) | |
| backgroundColor = .clear | |
| layer.addSublayer(dimmingLayer) | |
| dimmingLayer.zPosition = -1 | |
| layer.addSublayer(cropRectangeLayer) | |
| cropRectangeLayer.zPosition = 1 | |
| let gesture = UIPanGestureRecognizer(minTouches: 1, | |
| maxTouches: .max) | |
| { [weak self] gesture in | |
| self?.handlePan(gesture) | |
| } | |
| addGestureRecognizer(gesture) | |
| } | |
| override func layoutSubviews() { | |
| super.layoutSubviews() | |
| cropRectangeLayer.frame = bounds | |
| } | |
| override open func point(inside point: CGPoint, with _: UIEvent?) -> Bool { | |
| let shrankRect = cropRect.insetBy(dx: tolerance, dy: tolerance) | |
| if shrankRect.contains(point) { | |
| return false | |
| } | |
| let extendedRect = cropRect.insetBy(dx: -tolerance, dy: -tolerance) | |
| let contains = extendedRect.contains(point) | |
| return contains | |
| } | |
| func handlePan(_ gesture: UIPanGestureRecognizer) { | |
| let point = gesture.location(in: self) | |
| switch gesture.state { | |
| case .began: | |
| let edge = matchEdge(point) | |
| state = .start(point, cropRect, edge) | |
| case .changed: | |
| switch state { | |
| case .none: | |
| break | |
| case let .start(start, crop, edge): | |
| state = .changing(start, crop, edge) | |
| let dx = point.x - start.x | |
| let dy = point.y - start.y | |
| let rect = newRect(old: crop, dx: dx, dy: dy, edge: edge) | |
| cropRect = rect | |
| case let .changing(start, crop, edge): | |
| state = .changing(start, crop, edge) | |
| let dx = point.x - start.x | |
| let dy = point.y - start.y | |
| let rect = newRect(old: crop, dx: dx, dy: dy, edge: edge) | |
| cropRect = rect | |
| } | |
| case .cancelled, | |
| .ended, | |
| .failed: | |
| state = .none | |
| case .possible: | |
| break | |
| @unknown default: | |
| break | |
| } | |
| } | |
| func matchEdge(_ point: CGPoint) -> UIRectEdge { | |
| let rect = cropRect | |
| // Corner hit test | |
| let corners: [(CGPoint, UIRectEdge)] = [ | |
| (CGPoint(x: rect.minX, y: rect.minY), [.top, .left]), | |
| (CGPoint(x: rect.maxX, y: rect.minY), [.top, .right]), | |
| (CGPoint(x: rect.maxX, y: rect.maxY), [.bottom, .right]), | |
| (CGPoint(x: rect.minX, y: rect.maxY), [.bottom, .left]), | |
| ] | |
| for (corner, edge) in corners { | |
| if hypot(point.x - corner.x, point.y - corner.y) < tolerance { | |
| return edge | |
| } | |
| } | |
| // Then test each edge as a segment (excluding corners) | |
| // Top edge | |
| if point.x > rect.minX + tolerance, point.x < rect.maxX - tolerance { | |
| if abs(point.y - rect.minY) < tolerance { | |
| return .top | |
| } | |
| } | |
| // Right edge | |
| if point.y > rect.minY + tolerance, point.y < rect.maxY - tolerance { | |
| if abs(point.x - rect.maxX) < tolerance { | |
| return .right | |
| } | |
| } | |
| // Bottom edge | |
| if point.x > rect.minX + tolerance, point.x < rect.maxX - tolerance { | |
| if abs(point.y - rect.maxY) < tolerance { | |
| return .bottom | |
| } | |
| } | |
| // Left edge | |
| if point.y > rect.minY + tolerance, point.y < rect.maxY - tolerance { | |
| if abs(point.x - rect.minX) < tolerance { | |
| return .left | |
| } | |
| } | |
| return [] | |
| } | |
| func newRect(old: CGRect, dx: CGFloat, dy: CGFloat, edge: UIRectEdge) -> CGRect { | |
| let minimumEdgeLength: CGFloat = 50.rw | |
| var minX: CGFloat = old.minX | |
| var minY: CGFloat = old.minY | |
| var maxX: CGFloat = old.maxX | |
| var maxY: CGFloat = old.maxY | |
| // update old rect with dx dy on edge | |
| // if one edge reach minimumEdgeLenght, keep the minimum. | |
| // Left edge | |
| if edge.contains(.left) { | |
| minX = minX + dx | |
| minX = min(minX, maxX - minimumEdgeLength) | |
| minX = min(minX, maxX - minimumEdgeLength) | |
| minX = max(minX, boundingRect.minX) | |
| minX = max(minX, allowRect.minX) | |
| } | |
| // Right edge | |
| if edge.contains(.right) { | |
| maxX = maxX + dx | |
| maxX = max(maxX, minX + minimumEdgeLength) | |
| maxX = max(maxX, minX + minimumEdgeLength) | |
| maxX = min(maxX, boundingRect.maxX) | |
| maxX = min(maxX, allowRect.maxX) | |
| } | |
| // Top edge | |
| if edge.contains(.top) { | |
| minY = minY + dy | |
| minY = min(minY, maxY - minimumEdgeLength) | |
| minY = min(minY, maxY - minimumEdgeLength) | |
| minY = max(minY, boundingRect.minY) | |
| minY = max(minY, allowRect.minY) | |
| } | |
| // Bottom edge | |
| if edge.contains(.bottom) { | |
| maxY = maxY + dy | |
| maxY = max(maxY, minY + minimumEdgeLength) | |
| maxY = max(maxY, minY + minimumEdgeLength) | |
| maxY = min(maxY, boundingRect.maxY) | |
| maxY = min(maxY, allowRect.maxY) | |
| } | |
| return CGRect(x: minX, y: minY, width: maxX - minX, height: maxY - minY) | |
| } | |
| func setCropRect(_ rect: CGRect, dragging: Bool?, animate: Bool = true) { | |
| _cropRect = rect | |
| if animate { | |
| let duration = UIView.inheritedAnimationDuration > 0 ? UIView.inheritedAnimationDuration : CATransaction.animationDuration() | |
| let anim = CABasicAnimation(keyPath: "cropRect") | |
| anim.fromValue = cropRectangeLayer.presentation()?.cropRect ?? cropRect | |
| anim.toValue = rect | |
| anim.duration = duration | |
| anim.timingFunction = CATransaction.animationTimingFunction() | |
| anim.isRemovedOnCompletion = true | |
| cropRectangeLayer.add(anim, forKey: "cropRect") | |
| cropRectangeLayer.cropRect = rect | |
| } else { | |
| CATransaction.begin() | |
| CATransaction.setDisableActions(true) | |
| cropRectangeLayer.cropRect = rect | |
| CATransaction.commit() | |
| } | |
| updateDimmingLayer(rect, dragging: dragging, animate: animate) | |
| } | |
| func updateDimmingLayer(_ rect: CGRect?, dragging: Bool?, animate: Bool = true) { | |
| let rect = rect ?? _cropRect | |
| let path = UIBezierPath(rect: bounds) | |
| path.append(UIBezierPath(rect: rect).reversing()) | |
| let dimColor = (dragging ?? state.isAdjustRect) ? UIColor.black.withAlphaComponent(0.3) : 0xFFF5_F6FA.argb | |
| if animate { | |
| let duration = UIView.inheritedAnimationDuration > 0 ? UIView.inheritedAnimationDuration : CATransaction.animationDuration() | |
| let dimAnimation = CABasicAnimation(keyPath: "path") | |
| dimAnimation.fromValue = dimmingLayer.path | |
| dimAnimation.toValue = path.cgPath | |
| dimAnimation.duration = duration | |
| dimAnimation.timingFunction = CATransaction.animationTimingFunction() | |
| dimAnimation.isRemovedOnCompletion = true | |
| let colorAnimation = CABasicAnimation(keyPath: "fillColor") | |
| colorAnimation.fromValue = dimmingLayer.fillColor | |
| colorAnimation.toValue = dimColor.cgColor | |
| colorAnimation.duration = duration | |
| colorAnimation.timingFunction = CATransaction.animationTimingFunction() | |
| colorAnimation.isRemovedOnCompletion = true | |
| dimmingLayer.add(dimAnimation, forKey: "path") | |
| dimmingLayer.add(colorAnimation, forKey: "color") | |
| dimmingLayer.path = path.cgPath | |
| dimmingLayer.fillColor = dimColor.cgColor | |
| } else { | |
| CATransaction.begin() | |
| CATransaction.setDisableActions(true) | |
| dimmingLayer.path = path.cgPath | |
| dimmingLayer.fillColor = dimColor.cgColor | |
| CATransaction.commit() | |
| } | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // | |
| // PhotoEditView.swift | |
| // FaceYoga | |
| // | |
| // Created by Huanan on 2025/8/1. | |
| // | |
| import Combine | |
| import MKKit13 | |
| import SDWebImage | |
| import Vision | |
| // MARK: - PhotoEditView | |
| class PhotoEditView: MKBaseView { | |
| static let animationDuration = 0.25 | |
| let scrollView = UIScrollView() | |
| lazy var imageView: UIImageView = { | |
| let box = UIImageView(image: image) | |
| box.contentMode = .scaleAspectFit | |
| box.isUserInteractionEnabled = false | |
| box.addSnpEdgesToSuper() | |
| return box | |
| }() | |
| lazy var cropOverlay: CropOverlayView = { | |
| let box = CropOverlayView() | |
| box.backgroundColor = .clear | |
| return box | |
| }() | |
| // MARK: | |
| private var hasSetup: Bool = false | |
| let minimunViewSize = CGSize(width: 50.rw, height: 50.rw) | |
| let insets = UIEdgeInsets(top: 20.rw, left: 20.rw, bottom: 20.rw, right: 20.rw) | |
| var boundingRect: CGRect { | |
| bounds.inset(by: insets) | |
| } | |
| @Published var isMovingOrZooming: Bool = false | |
| private var isModifyingContentOffsets: Bool = false | |
| lazy var isModifying = Publishers.CombineLatest( | |
| cropOverlay.isAdjustRect, | |
| $isMovingOrZooming | |
| ).map { $0.0 || $0.1 }.removeDuplicates().eraseToAnyPublisher() | |
| @Published var isTaskPending = false | |
| private var updateWaitTask: Task<Void, any Error>? | |
| // MARK: | |
| let roi: CGRect | |
| let image: UIImage? | |
| init(image: UIImage?, roi: CGRect = .fullROI) { | |
| self.image = image | |
| self.roi = roi | |
| super.init(frame: .zero) | |
| setupViews() | |
| setupBinding() | |
| } | |
| func setupViews() { | |
| scrollView.delegate = self | |
| addSubview(scrollView) | |
| scrollView.addSnpSubview(imageView) | |
| addSubview(cropOverlay) | |
| } | |
| func setupBinding() { | |
| isModifying.dropFirst() | |
| .sink { [weak self] isModifying in | |
| if isModifying { | |
| self?.updateWaitTask?.cancel() | |
| } else { | |
| self?.pendingUpdateCanvas() | |
| } | |
| }.store(in: self) | |
| scrollView.publisher(for: \.contentOffset) | |
| .dropFirst() | |
| .removeDuplicates() | |
| .sink { [weak self] _ in | |
| self?.updateAllowRect() | |
| }.store(in: self) | |
| $isMovingOrZooming.filter { $0 }.sink { [weak self] _ in | |
| self?.cropOverlay.updateDimmingLayer(nil, dragging: true) | |
| }.store(in: self) | |
| scrollView.panGestureRecognizer.publisher(for: \.state).sink { [weak self] state in | |
| switch state { | |
| case .began: | |
| self?.isMovingOrZooming = true | |
| case .cancelled, | |
| .ended, | |
| .failed: | |
| self?.isMovingOrZooming = false | |
| default: | |
| break | |
| } | |
| }.store(in: self) | |
| scrollView.pinchGestureRecognizer?.publisher(for: \.state).sink { [weak self] state in | |
| switch state { | |
| case .began: | |
| self?.isMovingOrZooming = true | |
| case .cancelled, | |
| .ended, | |
| .failed: | |
| self?.isMovingOrZooming = false | |
| default: | |
| break | |
| } | |
| }.store(in: self) | |
| } | |
| override func layoutSubviews() { | |
| super.layoutSubviews() | |
| cropOverlay.frame = bounds | |
| scrollView.frame = bounds | |
| scrollView.contentInset = UIEdgeInsets(top: bounds.size.height - minimunViewSize.height, | |
| left: bounds.size.width - minimunViewSize.width, | |
| bottom: bounds.size.height - minimunViewSize.height, | |
| right: bounds.size.width - minimunViewSize.width) | |
| if !hasSetup, | |
| bounds != .zero, | |
| cropOverlay.bounds.height != 0, | |
| let image = imageView.image | |
| { | |
| let cropRect = VNImageRectForNormalizedRect(roi, | |
| Int(image.size.width), | |
| Int(image.size.height)) | |
| let rr = imageView.convert(cropRect, to: cropOverlay) | |
| cropOverlay.setCropRect(rr, dragging: false, animate: false) | |
| updateCanvas(animate: false) | |
| hasSetup = true | |
| } | |
| } | |
| } | |
| // MARK: constrains | |
| extension PhotoEditView { | |
| func updateAllowRect() { | |
| let imageRectInCrop = imageView.convert(imageView.bounds, to: cropOverlay) | |
| cropOverlay.allowRect = imageRectInCrop | |
| } | |
| func asyncUpdateAllowRect() { | |
| Task { | |
| let imageRectInCrop = self.imageView.convert(self.imageView.bounds, to: self.cropOverlay) | |
| self.cropOverlay.allowRect = imageRectInCrop | |
| } | |
| } | |
| func clampContentOffset(scrollView: UIScrollView, into rect: CGRect) { | |
| let offset = scrollView.contentOffset | |
| let contentSize = scrollView.contentSize | |
| let minX = -rect.minX | |
| let minY = -rect.minY | |
| let maxX = contentSize.width - rect.maxX | |
| let maxY = contentSize.height - rect.maxY | |
| let newOffset = CGPoint(x: offset.x.bounded(between: minX, and: maxX), | |
| y: offset.y.bounded(between: minY, and: maxY)) | |
| if offset != newOffset { | |
| scrollView.contentOffset = newOffset | |
| } | |
| } | |
| enum ZoomConstraint { | |
| case bounding | |
| case cropping | |
| } | |
| func constrainZoomScale(for constraint: ZoomConstraint) { | |
| guard let image = imageView.image else { return } | |
| scrollView.minimumZoomScale = constrainZoomScale(size: image.size, for: constraint) | |
| } | |
| func constrainZoomScale(size: CGSize, for constraint: ZoomConstraint) -> CGFloat { | |
| switch constraint { | |
| case .bounding: | |
| CGAffineTransform(aspectFit: size, in: boundingRect.size).a | |
| case .cropping: | |
| CGAffineTransform(aspectFill: size, in: cropOverlay.cropRect.size).a | |
| } | |
| } | |
| } | |
| // MARK: Rotate | |
| extension PhotoEditView { | |
| func rotate90DegreeClockwise(complete: VoidFunction? = nil) { | |
| guard let image = imageView.image else { return } | |
| guard let snap = cropedImage() else { return } | |
| // prepare | |
| let cropRect = cropOverlay.cropRect | |
| let centerInCrop = cropRect.center | |
| let cropCenterInUnifiedSpace = cropOverlay.convert(centerInCrop, to: self) | |
| func rotate90CW(point: CGPoint, imageSize: CGSize) -> CGPoint { | |
| let xPrime = imageSize.height - point.y | |
| let yPrime = point.x | |
| return CGPoint(x: xPrime, y: yPrime) | |
| } | |
| let cropCenterInImageView = convert(cropCenterInUnifiedSpace, to: imageView) // a | |
| let rotatedCropCenterInImageView = rotate90CW(point: cropCenterInImageView, imageSize: image.size) | |
| let cropCenterInScrollView = imageView.convert(cropCenterInImageView, to: scrollView) | |
| let cropCenterDeltaInScrollView = CGSize(width: cropCenterInScrollView.x - scrollView.contentOffset.x, | |
| height: cropCenterInScrollView.y - scrollView.contentOffset.y) | |
| // action | |
| guard let newImage = image.rotated(by: .pi / 2) else { | |
| return | |
| } | |
| imageView.image = newImage | |
| let rotatedCropCenterInScrollView = imageView.convert(rotatedCropCenterInImageView, to: scrollView) | |
| let rorateContentOffset = CGPoint(x: rotatedCropCenterInScrollView.x - cropCenterDeltaInScrollView.width, | |
| y: rotatedCropCenterInScrollView.y - cropCenterDeltaInScrollView.height) | |
| withoutScrollAdjustment { | |
| scrollView.setContentOffset(rorateContentOffset, animated: false) | |
| } | |
| let newCropRect = cropOverlay.cropRect.rotateOnCentre(by: .pi / 2) | |
| cropOverlay.setCropRect(newCropRect, dragging: false, animate: false) | |
| updateCanvas() | |
| // animation | |
| let animateView = UIImageView(image: snap) | |
| animateView.frame = cropRect | |
| addSubview(animateView) | |
| let newCropRectAfterCanvasUpdated = rectForFittingBoundingRect(ratio: newCropRect.ratio) | |
| scrollView.isHidden = true | |
| cropOverlay.isHidden = true | |
| UIView.animate(withDuration: Self.animationDuration) { | |
| let scaleX = newCropRectAfterCanvasUpdated.height / cropRect.width | |
| let scaleY = newCropRectAfterCanvasUpdated.width / cropRect.height | |
| animateView.transform = .identity.scaledBy(x: scaleX, y: scaleY).rotated(by: .pi / 2) | |
| } completion: { _ in | |
| animateView.removeFromSuperview() | |
| self.scrollView.isHidden = false | |
| self.cropOverlay.isHidden = false | |
| complete?() | |
| } | |
| } | |
| } | |
| // MARK: Canvas | |
| extension PhotoEditView { | |
| func pendingUpdateCanvas() { | |
| updateWaitTask?.cancel() | |
| isTaskPending = true | |
| updateWaitTask = Task { | |
| try await Task.sleep(nanoseconds: 750_000_000) | |
| updateCanvas() | |
| isTaskPending = false | |
| } | |
| } | |
| func updateCanvas(animate: Bool = true) { | |
| if boundingRect.size == .zero, cropOverlay.cropRect.size == .zero { | |
| return | |
| } | |
| let cropRect = cropOverlay.cropRect | |
| let cropRectCenter = cropRect.center | |
| let bounding = boundingRect | |
| let boundingCenter = bounding.center | |
| CATransaction.begin() | |
| CATransaction.setDisableActions(!animate) | |
| CATransaction.setAnimationDuration(Self.animationDuration) | |
| CATransaction.setAnimationTimingFunction(CAMediaTimingFunction(name: .easeInEaseOut)) | |
| // Updata Crop Rect | |
| let newRect = rectForFittingBoundingRect(ratio: cropRect.ratio) | |
| cropOverlay.setCropRect(newRect, dragging: false, animate: animate) | |
| withoutScrollAdjustment { | |
| // Updata Scroll Zoom | |
| let ratio = CGAffineTransform(aspectFit: cropRect.size, in: bounding.size).a | |
| scrollView.maximumZoomScale = max(2, scrollView.zoomScale * ratio) | |
| constrainZoomScale(for: .bounding) | |
| scrollView.setZoomScale(scrollView.zoomScale * ratio, animated: animate) | |
| // Updata Scroll Offset | |
| let offset = scrollView.contentOffset // should after zoom | |
| let newOffset = CGPoint(x: offset.x - (boundingCenter.x - cropRectCenter.x) * ratio, | |
| y: offset.y - (boundingCenter.y - cropRectCenter.y) * ratio) | |
| scrollView.setContentOffset(newOffset, animated: animate) | |
| } | |
| CATransaction.commit() | |
| asyncUpdateAllowRect() | |
| } | |
| func rectForFittingBoundingRect(ratio: CGFloat) -> CGRect { | |
| CGRect(center: boundingRect.center, | |
| size: .aspectFit(ratio: ratio, into: boundingRect.size)) | |
| } | |
| } | |
| // MARK: Result | |
| extension PhotoEditView { | |
| func cropedImage() -> UIImage? { | |
| guard let image = imageView.image else { | |
| return self.image | |
| } | |
| let crop = cropOverlay.convert(cropOverlay.cropRect, to: imageView) | |
| let croped = image.sd_croppedImage(with: crop) | |
| return croped | |
| } | |
| } | |
| // MARK: UIScrollViewDelegate | |
| extension PhotoEditView: UIScrollViewDelegate { | |
| func viewForZooming(in _: UIScrollView) -> UIView? { | |
| imageView | |
| } | |
| func withoutScrollAdjustment(op: VoidFunction) { | |
| isModifyingContentOffsets = true | |
| op() | |
| isModifyingContentOffsets = false | |
| } | |
| func scrollViewDidScroll(_ scrollView: UIScrollView) { | |
| guard !isModifyingContentOffsets else { return } // IMPORTANT | |
| let cropRect = cropOverlay.cropRect | |
| guard cropRect != .zero else { return } | |
| clampContentOffset(scrollView: scrollView, into: cropRect) | |
| } | |
| func scrollViewDidZoom(_: UIScrollView) { | |
| constrainZoomScale(for: .cropping) | |
| } | |
| func scrollViewDidEndZooming(_: UIScrollView, with _: UIView?, atScale _: CGFloat) { | |
| constrainZoomScale(for: .bounding) | |
| } | |
| } | |
| extension CGRect { | |
| func rotateOnCentre(by angle: CGFloat) -> CGRect { | |
| let centre = CGPoint(x: midX, y: midY) | |
| let t = CGAffineTransform(translationX: centre.x, y: centre.y) | |
| .rotated(by: angle) | |
| .translatedBy(x: -centre.x, y: -centre.y) | |
| return applying(t) | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment