Some checks failed
CI / Scraper / Lint (pull_request) Failing after 13s
CI / Scraper / Test (pull_request) Successful in 18s
CI / UI / Build (pull_request) Successful in 18s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Test (push) Has been cancelled
iOS CI / Build (push) Has been cancelled
iOS CI / Build (pull_request) Successful in 2m1s
iOS CI / Test (pull_request) Successful in 5m49s
271 lines
11 KiB
Swift
271 lines
11 KiB
Swift
import SwiftUI
|
||
|
||
// MARK: - AvatarCropView
|
||
// A sheet that lets the user pan and pinch a photo to fill a 1:1 circular crop region.
|
||
// Call: .sheet(item: $cropImage) { AvatarCropView(image: $0.image, onConfirm: { croppedData in … }) }
|
||
|
||
struct AvatarCropView: View {
|
||
let image: UIImage
|
||
let onConfirm: (Data) -> Void
|
||
let onCancel: () -> Void
|
||
|
||
// Crop circle diameter (points)
|
||
private let cropSize: CGFloat = 280
|
||
|
||
// Pan/zoom state — all in screen points, relative to the image's natural fill-fitted frame
|
||
@State private var scale: CGFloat = 1.0
|
||
@State private var lastScale: CGFloat = 1.0
|
||
@State private var offset: CGSize = .zero
|
||
@State private var lastOffset: CGSize = .zero
|
||
|
||
// Container size captured from GeometryReader
|
||
@State private var containerSize: CGSize = .zero
|
||
|
||
var body: some View {
|
||
NavigationStack {
|
||
GeometryReader { geo in
|
||
ZStack {
|
||
Color.black.ignoresSafeArea()
|
||
|
||
// Draggable / pinchable image
|
||
Image(uiImage: image)
|
||
.resizable()
|
||
.scaledToFill()
|
||
.frame(width: geo.size.width, height: geo.size.height)
|
||
.scaleEffect(scale, anchor: .center)
|
||
.offset(offset)
|
||
.gesture(
|
||
SimultaneousGesture(
|
||
MagnificationGesture()
|
||
.onChanged { value in
|
||
let proposed = lastScale * value
|
||
scale = max(minScale(in: geo.size), proposed)
|
||
}
|
||
.onEnded { _ in
|
||
lastScale = scale
|
||
clampOffset(in: geo.size)
|
||
lastOffset = offset
|
||
},
|
||
DragGesture()
|
||
.onChanged { value in
|
||
let proposed = CGSize(
|
||
width: lastOffset.width + value.translation.width,
|
||
height: lastOffset.height + value.translation.height
|
||
)
|
||
offset = clampedOffset(proposed, in: geo.size)
|
||
}
|
||
.onEnded { _ in
|
||
lastOffset = offset
|
||
}
|
||
)
|
||
)
|
||
.clipped()
|
||
|
||
// Dim overlay with transparent crop circle cut out
|
||
CropOverlay(cropSize: cropSize, containerSize: geo.size)
|
||
.allowsHitTesting(false)
|
||
}
|
||
.onAppear {
|
||
containerSize = geo.size
|
||
fitImageInitially(in: geo.size)
|
||
}
|
||
}
|
||
.navigationTitle("Crop Photo")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .topBarLeading) {
|
||
Button("Cancel", action: onCancel)
|
||
.foregroundStyle(.white)
|
||
}
|
||
ToolbarItem(placement: .topBarTrailing) {
|
||
Button("Use Photo") {
|
||
confirmCrop()
|
||
}
|
||
.fontWeight(.semibold)
|
||
.foregroundStyle(.amber)
|
||
}
|
||
}
|
||
.toolbarColorScheme(.dark, for: .navigationBar)
|
||
}
|
||
}
|
||
|
||
// MARK: - Initial fit
|
||
|
||
private func fitImageInitially(in size: CGSize) {
|
||
// The image is displayed with .scaledToFill() in the container (size).
|
||
// That means one dimension equals the container and the other overflows.
|
||
// We want the image to be just large enough that the crop circle is fully
|
||
// covered — i.e. the fill-fitted image's shorter displayed dimension >= cropSize.
|
||
//
|
||
// .scaledToFill fills the container, so the image already covers the container.
|
||
// The minimum scale that covers the crop square is therefore 1.0 (image already
|
||
// fills container which is >= cropSize on both axes).
|
||
// We keep scale = 1.0 and centre the offset.
|
||
scale = 1.0
|
||
lastScale = 1.0
|
||
offset = .zero
|
||
lastOffset = .zero
|
||
}
|
||
|
||
// MARK: - Clamping helpers
|
||
|
||
/// Minimum scale: the image (at .scaledToFill in container) must cover the crop square.
|
||
/// At scale=1 the image already fills the container; cropSize <= container dimension,
|
||
/// so 1.0 is always sufficient. We cap at 1.0 to prevent zooming out below fill.
|
||
private func minScale(in containerSize: CGSize) -> CGFloat {
|
||
return 1.0
|
||
}
|
||
|
||
/// The displayed (fill-fitted) image size in the container at the given user scale.
|
||
private func displayedImageSize(in containerSize: CGSize, userScale: CGFloat) -> CGSize {
|
||
let imgAspect = image.size.width / image.size.height
|
||
let containerAspect = containerSize.width / containerSize.height
|
||
|
||
// .scaledToFill base size before user scale
|
||
let baseWidth: CGFloat
|
||
let baseHeight: CGFloat
|
||
if imgAspect > containerAspect {
|
||
// image is wider — height fills container
|
||
baseHeight = containerSize.height
|
||
baseWidth = baseHeight * imgAspect
|
||
} else {
|
||
// image is taller — width fills container
|
||
baseWidth = containerSize.width
|
||
baseHeight = baseWidth / imgAspect
|
||
}
|
||
return CGSize(width: baseWidth * userScale, height: baseHeight * userScale)
|
||
}
|
||
|
||
/// Maximum offset so the crop square is always covered by the image.
|
||
private func clampedOffset(_ proposed: CGSize, in containerSize: CGSize) -> CGSize {
|
||
let displayed = displayedImageSize(in: containerSize, userScale: scale)
|
||
// Half of how much the image overflows the container on each axis
|
||
let maxX = max(0, (displayed.width - cropSize) / 2)
|
||
let maxY = max(0, (displayed.height - cropSize) / 2)
|
||
return CGSize(
|
||
width: min(maxX, max(-maxX, proposed.width)),
|
||
height: min(maxY, max(-maxY, proposed.height))
|
||
)
|
||
}
|
||
|
||
private func clampOffset(in containerSize: CGSize) {
|
||
offset = clampedOffset(offset, in: containerSize)
|
||
}
|
||
|
||
// MARK: - Crop
|
||
|
||
private func confirmCrop() {
|
||
let size = containerSize.width > 0 ? containerSize : CGSize(width: 390, height: 844)
|
||
let outputSize = CGSize(width: 400, height: 400)
|
||
|
||
// --- Step 1: compute the fill-fitted base display size ---
|
||
let imgAspect = image.size.width / image.size.height
|
||
let containerAspect = size.width / size.height
|
||
|
||
let baseDisplayW: CGFloat
|
||
let baseDisplayH: CGFloat
|
||
if imgAspect > containerAspect {
|
||
baseDisplayH = size.height
|
||
baseDisplayW = baseDisplayH * imgAspect
|
||
} else {
|
||
baseDisplayW = size.width
|
||
baseDisplayH = baseDisplayW / imgAspect
|
||
}
|
||
|
||
// Displayed image size after user zoom
|
||
let displayW = baseDisplayW * scale
|
||
let displayH = baseDisplayH * scale
|
||
|
||
// --- Step 2: the crop square centre is the container centre ---
|
||
// The image centre (after offset) in container coords:
|
||
let imageCentreX = size.width / 2 + offset.width
|
||
let imageCentreY = size.height / 2 + offset.height
|
||
|
||
// Top-left of the crop square in container coords:
|
||
let cropOriginX = (size.width - cropSize) / 2
|
||
let cropOriginY = (size.height - cropSize) / 2
|
||
|
||
// Top-left of the crop square relative to the image's top-left in display space:
|
||
let imageOriginX = imageCentreX - displayW / 2
|
||
let imageOriginY = imageCentreY - displayH / 2
|
||
|
||
let cropInImageX = cropOriginX - imageOriginX // pixels in display space
|
||
let cropInImageY = cropOriginY - imageOriginY
|
||
|
||
// --- Step 3: convert display-space coords to image pixel coords ---
|
||
let displayToPixelX = image.size.width / displayW
|
||
let displayToPixelY = image.size.height / displayH
|
||
|
||
let pixelX = cropInImageX * displayToPixelX
|
||
let pixelY = cropInImageY * displayToPixelY
|
||
let pixelW = cropSize * displayToPixelX
|
||
let pixelH = cropSize * displayToPixelY
|
||
|
||
let cropRect = CGRect(x: pixelX, y: pixelY, width: pixelW, height: pixelH)
|
||
.intersection(CGRect(origin: .zero, size: image.size))
|
||
|
||
guard cropRect.width > 0, cropRect.height > 0 else {
|
||
// Fallback: use entire image
|
||
if let jpeg = image.jpegData(compressionQuality: 0.9) { onConfirm(jpeg) }
|
||
return
|
||
}
|
||
|
||
// --- Step 4: render cropped region into 400×400 ---
|
||
let renderer = UIGraphicsImageRenderer(size: outputSize)
|
||
let cropped = renderer.image { _ in
|
||
// Draw only the cropRect portion of the image scaled to fill outputSize
|
||
let destRect = CGRect(origin: .zero, size: outputSize)
|
||
// UIImage.draw(in:) draws the full image; we use CGImage cropping instead
|
||
if let cgImg = image.cgImage?.cropping(to: cropRect) {
|
||
let croppedUI = UIImage(cgImage: cgImg, scale: image.scale, orientation: image.imageOrientation)
|
||
croppedUI.draw(in: destRect)
|
||
} else {
|
||
image.draw(in: destRect)
|
||
}
|
||
}
|
||
|
||
if let jpeg = cropped.jpegData(compressionQuality: 0.9) {
|
||
onConfirm(jpeg)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Crop overlay
|
||
|
||
private struct CropOverlay: View {
|
||
let cropSize: CGFloat
|
||
let containerSize: CGSize
|
||
|
||
var body: some View {
|
||
Canvas { context, size in
|
||
// Fill entire canvas with semi-transparent black
|
||
context.fill(Path(CGRect(origin: .zero, size: size)), with: .color(.black.opacity(0.55)))
|
||
// Cut out the crop circle in the centre
|
||
let origin = CGPoint(
|
||
x: (size.width - cropSize) / 2,
|
||
y: (size.height - cropSize) / 2
|
||
)
|
||
let cropRect = CGRect(origin: origin, size: CGSize(width: cropSize, height: cropSize))
|
||
context.blendMode = .destinationOut
|
||
context.fill(Path(ellipseIn: cropRect), with: .color(.white))
|
||
}
|
||
.compositingGroup()
|
||
.overlay {
|
||
// Amber circle border around the crop region
|
||
let origin = CGPoint(
|
||
x: (containerSize.width - cropSize) / 2,
|
||
y: (containerSize.height - cropSize) / 2
|
||
)
|
||
Circle()
|
||
.stroke(Color.amber.opacity(0.8), lineWidth: 2)
|
||
.frame(width: cropSize, height: cropSize)
|
||
.position(
|
||
x: origin.x + cropSize / 2,
|
||
y: origin.y + cropSize / 2
|
||
)
|
||
}
|
||
.frame(width: containerSize.width, height: containerSize.height)
|
||
.allowsHitTesting(false)
|
||
}
|
||
}
|