Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2026-01-09 10:16:00 -06:00
parent 1258f5dcd3
commit 05aedadfc3

View File

@ -46,6 +46,9 @@ struct PhotoCropperSheet: View {
@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
@State private var rotation: Angle = .zero
@State private var lastRotation: Angle = .zero
@State private var fixedRotation: Angle = .zero // For 90° toolbar rotations
@State private var containerSize: CGSize = .zero @State private var containerSize: CGSize = .zero
// Horizontal padding for the crop area // Horizontal padding for the crop area
@ -84,10 +87,11 @@ struct PhotoCropperSheet: View {
Image(uiImage: uiImage) Image(uiImage: uiImage)
.resizable() .resizable()
.scaledToFill() .scaledToFill()
.rotationEffect(rotation + fixedRotation)
.scaleEffect(scale) .scaleEffect(scale)
.offset(offset) .offset(offset)
.gesture(dragGesture) .gesture(dragGesture)
.gesture(magnificationGesture) .gesture(combinedPinchRotateGesture)
.frame(width: geometry.size.width, height: geometry.size.height) .frame(width: geometry.size.width, height: geometry.size.height)
.clipped() .clipped()
} }
@ -119,12 +123,31 @@ struct PhotoCropperSheet: View {
.foregroundStyle(.white) .foregroundStyle(.white)
} }
ToolbarItem(placement: .principal) { ToolbarItem(placement: .principal) {
HStack(spacing: Design.Spacing.xLarge) {
// Rotate 90° left
Button {
rotate90Left()
} label: {
Image(systemName: "rotate.left")
.foregroundStyle(.white)
}
// Reset all transforms
Button { Button {
resetTransform() resetTransform()
} label: { } label: {
Image(systemName: "arrow.counterclockwise") Image(systemName: "arrow.counterclockwise")
.foregroundStyle(.white) .foregroundStyle(.white)
} }
// Rotate 90° right
Button {
rotate90Right()
} label: {
Image(systemName: "rotate.right")
.foregroundStyle(.white)
}
}
} }
ToolbarItem(placement: .confirmationAction) { ToolbarItem(placement: .confirmationAction) {
Button(String.localized("Done")) { Button(String.localized("Done")) {
@ -153,15 +176,27 @@ struct PhotoCropperSheet: View {
} }
} }
private var magnificationGesture: some Gesture { private var combinedPinchRotateGesture: some Gesture {
MagnificationGesture() SimultaneousGesture(
MagnificationGesture(),
RotationGesture()
)
.onChanged { value in .onChanged { value in
let newScale = lastScale * value // Handle pinch (magnification)
if let magnification = value.first {
let newScale = lastScale * magnification
// Limit scale between 0.5x and 5x // Limit scale between 0.5x and 5x
scale = min(max(newScale, 0.5), 5.0) scale = min(max(newScale, 0.5), 5.0)
} }
// Handle rotation
if let rotationValue = value.second {
rotation = lastRotation + rotationValue
}
}
.onEnded { _ in .onEnded { _ in
lastScale = scale lastScale = scale
lastRotation = rotation
} }
} }
@ -173,6 +208,21 @@ struct PhotoCropperSheet: View {
lastScale = 1.0 lastScale = 1.0
offset = .zero offset = .zero
lastOffset = .zero lastOffset = .zero
rotation = .zero
lastRotation = .zero
fixedRotation = .zero
}
}
private func rotate90Left() {
withAnimation(.spring(duration: Design.Animation.springDuration)) {
fixedRotation -= .degrees(90)
}
}
private func rotate90Right() {
withAnimation(.spring(duration: Design.Animation.springDuration)) {
fixedRotation += .degrees(90)
} }
} }
@ -186,7 +236,11 @@ struct PhotoCropperSheet: View {
} }
// First, normalize the image orientation so we're working with correctly oriented pixels // First, normalize the image orientation so we're working with correctly oriented pixels
let normalizedImage = normalizeImageOrientation(uiImage) let orientedImage = normalizeImageOrientation(uiImage)
// Apply total rotation (gesture rotation + fixed rotation)
let totalRotation = rotation + fixedRotation
let normalizedImage = rotateImage(orientedImage, by: totalRotation)
let imageSize = normalizedImage.size let imageSize = normalizedImage.size
// Guard against zero container size // Guard against zero container size
@ -321,6 +375,44 @@ struct PhotoCropperSheet: View {
image.draw(at: .zero) image.draw(at: .zero)
} }
} }
/// Rotates an image by the specified angle
private func rotateImage(_ image: UIImage, by angle: Angle) -> UIImage {
// If no rotation needed, return original
guard abs(angle.radians) > 0.001 else { return image }
let radians = CGFloat(angle.radians)
// Calculate the size of the rotated image
let rotatedRect = CGRect(origin: .zero, size: image.size)
.applying(CGAffineTransform(rotationAngle: radians))
let rotatedSize = CGSize(
width: abs(rotatedRect.width),
height: abs(rotatedRect.height)
)
let format = UIGraphicsImageRendererFormat()
format.scale = 1 // Use 1:1 scale for output
let renderer = UIGraphicsImageRenderer(size: rotatedSize, format: format)
return renderer.image { context in
let cgContext = context.cgContext
// Move to center
cgContext.translateBy(x: rotatedSize.width / 2, y: rotatedSize.height / 2)
// Rotate
cgContext.rotate(by: radians)
// Draw image centered
image.draw(in: CGRect(
x: -image.size.width / 2,
y: -image.size.height / 2,
width: image.size.width,
height: image.size.height
))
}
}
} }
// MARK: - Crop Overlay // MARK: - Crop Overlay