Files
libnovel/ios/LibNovel/LibNovel/Views/Profile/AvatarCropView.swift
Admin 937ba052fc
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
fix(ios): rewrite avatar crop — correct pixel mapping and drag clamping
2026-03-10 21:14:46 +05:00

271 lines
11 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}