Skip to content

Instantly share code, notes, and snippets.

@AFutureD
Created August 12, 2026 06:35
Show Gist options
  • Select an option

  • Save AFutureD/dcd9763ec26c31a5396afe0877e1ea57 to your computer and use it in GitHub Desktop.

Select an option

Save AFutureD/dcd9763ec26c31a5396afe0877e1ea57 to your computer and use it in GitHub Desktop.
//
// 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()
}
}
}
//
// 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