fix(ios): rewrite avatar crop — correct pixel mapping and drag clamping
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
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
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
// MARK: - AvatarCropView
|
// MARK: - AvatarCropView
|
||||||
// A sheet that lets the user pan and pinch a photo to fill a 1:1 square crop region.
|
// 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 … }) }
|
// Call: .sheet(item: $cropImage) { AvatarCropView(image: $0.image, onConfirm: { croppedData in … }) }
|
||||||
|
|
||||||
struct AvatarCropView: View {
|
struct AvatarCropView: View {
|
||||||
@@ -9,15 +9,18 @@ struct AvatarCropView: View {
|
|||||||
let onConfirm: (Data) -> Void
|
let onConfirm: (Data) -> Void
|
||||||
let onCancel: () -> Void
|
let onCancel: () -> Void
|
||||||
|
|
||||||
// Crop square side length (points) — matched to the web 400 px target
|
// Crop circle diameter (points)
|
||||||
private let cropSize: CGFloat = 280
|
private let cropSize: CGFloat = 280
|
||||||
|
|
||||||
// Pan/zoom state
|
// 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 scale: CGFloat = 1.0
|
||||||
@State private var lastScale: CGFloat = 1.0
|
@State private var lastScale: CGFloat = 1.0
|
||||||
@State private var offset: CGSize = .zero
|
@State private var offset: CGSize = .zero
|
||||||
@State private var lastOffset: CGSize = .zero
|
@State private var lastOffset: CGSize = .zero
|
||||||
|
|
||||||
|
// Container size captured from GeometryReader
|
||||||
|
@State private var containerSize: CGSize = .zero
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
GeometryReader { geo in
|
GeometryReader { geo in
|
||||||
@@ -29,23 +32,27 @@ struct AvatarCropView: View {
|
|||||||
.resizable()
|
.resizable()
|
||||||
.scaledToFill()
|
.scaledToFill()
|
||||||
.frame(width: geo.size.width, height: geo.size.height)
|
.frame(width: geo.size.width, height: geo.size.height)
|
||||||
.scaleEffect(scale)
|
.scaleEffect(scale, anchor: .center)
|
||||||
.offset(offset)
|
.offset(offset)
|
||||||
.gesture(
|
.gesture(
|
||||||
SimultaneousGesture(
|
SimultaneousGesture(
|
||||||
MagnificationGesture()
|
MagnificationGesture()
|
||||||
.onChanged { value in
|
.onChanged { value in
|
||||||
scale = max(1.0, lastScale * value)
|
let proposed = lastScale * value
|
||||||
|
scale = max(minScale(in: geo.size), proposed)
|
||||||
}
|
}
|
||||||
.onEnded { _ in
|
.onEnded { _ in
|
||||||
lastScale = scale
|
lastScale = scale
|
||||||
|
clampOffset(in: geo.size)
|
||||||
|
lastOffset = offset
|
||||||
},
|
},
|
||||||
DragGesture()
|
DragGesture()
|
||||||
.onChanged { value in
|
.onChanged { value in
|
||||||
offset = CGSize(
|
let proposed = CGSize(
|
||||||
width: lastOffset.width + value.translation.width,
|
width: lastOffset.width + value.translation.width,
|
||||||
height: lastOffset.height + value.translation.height
|
height: lastOffset.height + value.translation.height
|
||||||
)
|
)
|
||||||
|
offset = clampedOffset(proposed, in: geo.size)
|
||||||
}
|
}
|
||||||
.onEnded { _ in
|
.onEnded { _ in
|
||||||
lastOffset = offset
|
lastOffset = offset
|
||||||
@@ -54,10 +61,14 @@ struct AvatarCropView: View {
|
|||||||
)
|
)
|
||||||
.clipped()
|
.clipped()
|
||||||
|
|
||||||
// Dim overlay with transparent crop square cut out
|
// Dim overlay with transparent crop circle cut out
|
||||||
CropOverlay(cropSize: cropSize, containerSize: geo.size)
|
CropOverlay(cropSize: cropSize, containerSize: geo.size)
|
||||||
.allowsHitTesting(false)
|
.allowsHitTesting(false)
|
||||||
}
|
}
|
||||||
|
.onAppear {
|
||||||
|
containerSize = geo.size
|
||||||
|
fitImageInitially(in: geo.size)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Crop Photo")
|
.navigationTitle("Crop Photo")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
@@ -76,43 +87,141 @@ struct AvatarCropView: View {
|
|||||||
}
|
}
|
||||||
.toolbarColorScheme(.dark, for: .navigationBar)
|
.toolbarColorScheme(.dark, for: .navigationBar)
|
||||||
}
|
}
|
||||||
.onAppear { fitImageInitially() }
|
}
|
||||||
|
|
||||||
|
// 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
|
// MARK: - Crop
|
||||||
|
|
||||||
private func fitImageInitially() {
|
|
||||||
// Scale image so its shorter dimension fills the crop square
|
|
||||||
let imgAspect = image.size.width / image.size.height
|
|
||||||
if imgAspect > 1 {
|
|
||||||
// wider than tall — fit height to cropSize
|
|
||||||
scale = cropSize / image.size.height * (image.size.height / image.size.width)
|
|
||||||
} else {
|
|
||||||
scale = 1.0
|
|
||||||
}
|
|
||||||
scale = max(1.0, scale)
|
|
||||||
lastScale = scale
|
|
||||||
}
|
|
||||||
|
|
||||||
private func confirmCrop() {
|
private func confirmCrop() {
|
||||||
// Render image at current pan/zoom into a 400×400 bitmap
|
let size = containerSize.width > 0 ? containerSize : CGSize(width: 390, height: 844)
|
||||||
let outputSize = CGSize(width: 400, height: 400)
|
let outputSize = CGSize(width: 400, height: 400)
|
||||||
let renderer = UIGraphicsImageRenderer(size: outputSize)
|
|
||||||
let cropped = renderer.image { ctx in
|
|
||||||
// We need to map from the SwiftUI transform back to image pixels.
|
|
||||||
// We render the raw UIImage into the output rect, applying the same
|
|
||||||
// scale / offset proportionally (normalised by crop square / container).
|
|
||||||
let screenCropSize: CGFloat = cropSize
|
|
||||||
// Scale factor: pixels per SwiftUI point in the output
|
|
||||||
let outputScale = outputSize.width / screenCropSize
|
|
||||||
|
|
||||||
ctx.cgContext.translateBy(x: outputSize.width / 2, y: outputSize.height / 2)
|
// --- Step 1: compute the fill-fitted base display size ---
|
||||||
ctx.cgContext.scaleBy(x: scale * outputScale, y: scale * outputScale)
|
let imgAspect = image.size.width / image.size.height
|
||||||
ctx.cgContext.translateBy(
|
let containerAspect = size.width / size.height
|
||||||
x: -image.size.width / 2 + (offset.width * outputScale / scale),
|
|
||||||
y: -image.size.height / 2 + (offset.height * outputScale / scale)
|
let baseDisplayW: CGFloat
|
||||||
)
|
let baseDisplayH: CGFloat
|
||||||
image.draw(at: .zero)
|
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) {
|
if let jpeg = cropped.jpegData(compressionQuality: 0.9) {
|
||||||
@@ -131,7 +240,7 @@ private struct CropOverlay: View {
|
|||||||
Canvas { context, size in
|
Canvas { context, size in
|
||||||
// Fill entire canvas with semi-transparent black
|
// Fill entire canvas with semi-transparent black
|
||||||
context.fill(Path(CGRect(origin: .zero, size: size)), with: .color(.black.opacity(0.55)))
|
context.fill(Path(CGRect(origin: .zero, size: size)), with: .color(.black.opacity(0.55)))
|
||||||
// Cut out the crop square in the centre
|
// Cut out the crop circle in the centre
|
||||||
let origin = CGPoint(
|
let origin = CGPoint(
|
||||||
x: (size.width - cropSize) / 2,
|
x: (size.width - cropSize) / 2,
|
||||||
y: (size.height - cropSize) / 2
|
y: (size.height - cropSize) / 2
|
||||||
|
|||||||
Reference in New Issue
Block a user