From 937ba052fc32a169cb53de3a1361f9813e626c07 Mon Sep 17 00:00:00 2001 From: Admin Date: Tue, 10 Mar 2026 21:14:46 +0500 Subject: [PATCH] =?UTF-8?q?fix(ios):=20rewrite=20avatar=20crop=20=E2=80=94?= =?UTF-8?q?=20correct=20pixel=20mapping=20and=20drag=20clamping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Views/Profile/AvatarCropView.swift | 185 ++++++++++++++---- 1 file changed, 147 insertions(+), 38 deletions(-) diff --git a/ios/LibNovel/LibNovel/Views/Profile/AvatarCropView.swift b/ios/LibNovel/LibNovel/Views/Profile/AvatarCropView.swift index 0670a6b..74dbd67 100644 --- a/ios/LibNovel/LibNovel/Views/Profile/AvatarCropView.swift +++ b/ios/LibNovel/LibNovel/Views/Profile/AvatarCropView.swift @@ -1,7 +1,7 @@ import SwiftUI // 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 … }) } struct AvatarCropView: View { @@ -9,15 +9,18 @@ struct AvatarCropView: View { let onConfirm: (Data) -> 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 - // 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 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 @@ -29,23 +32,27 @@ struct AvatarCropView: View { .resizable() .scaledToFill() .frame(width: geo.size.width, height: geo.size.height) - .scaleEffect(scale) + .scaleEffect(scale, anchor: .center) .offset(offset) .gesture( SimultaneousGesture( MagnificationGesture() .onChanged { value in - scale = max(1.0, lastScale * value) + 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 - offset = CGSize( + 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 @@ -54,10 +61,14 @@ struct AvatarCropView: View { ) .clipped() - // Dim overlay with transparent crop square cut out + // 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) @@ -76,43 +87,141 @@ struct AvatarCropView: View { } .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 - 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() { - // 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 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) - ctx.cgContext.scaleBy(x: scale * outputScale, y: scale * outputScale) - ctx.cgContext.translateBy( - x: -image.size.width / 2 + (offset.width * outputScale / scale), - y: -image.size.height / 2 + (offset.height * outputScale / scale) - ) - image.draw(at: .zero) + // --- 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) { @@ -131,7 +240,7 @@ private struct CropOverlay: 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 square in the centre + // Cut out the crop circle in the centre let origin = CGPoint( x: (size.width - cropSize) / 2, y: (size.height - cropSize) / 2