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

This commit is contained in:
Admin
2026-03-10 21:14:46 +05:00
parent 479d201da9
commit 937ba052fc

View File

@@ -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