From 05aedadfc3b7fa3fe672a978a5ff1a0a2242398e Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Fri, 9 Jan 2026 10:16:00 -0600 Subject: [PATCH] Signed-off-by: Matt Bruce --- .../Views/Sheets/PhotoCropperSheet.swift | 118 ++++++++++++++++-- 1 file changed, 105 insertions(+), 13 deletions(-) diff --git a/BusinessCard/Views/Sheets/PhotoCropperSheet.swift b/BusinessCard/Views/Sheets/PhotoCropperSheet.swift index 7183c36..18ccc24 100644 --- a/BusinessCard/Views/Sheets/PhotoCropperSheet.swift +++ b/BusinessCard/Views/Sheets/PhotoCropperSheet.swift @@ -46,6 +46,9 @@ struct PhotoCropperSheet: View { @State private var lastScale: CGFloat = 1.0 @State private var offset: 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 // Horizontal padding for the crop area @@ -84,10 +87,11 @@ struct PhotoCropperSheet: View { Image(uiImage: uiImage) .resizable() .scaledToFill() + .rotationEffect(rotation + fixedRotation) .scaleEffect(scale) .offset(offset) .gesture(dragGesture) - .gesture(magnificationGesture) + .gesture(combinedPinchRotateGesture) .frame(width: geometry.size.width, height: geometry.size.height) .clipped() } @@ -119,11 +123,30 @@ struct PhotoCropperSheet: View { .foregroundStyle(.white) } ToolbarItem(placement: .principal) { - Button { - resetTransform() - } label: { - Image(systemName: "arrow.counterclockwise") - .foregroundStyle(.white) + HStack(spacing: Design.Spacing.xLarge) { + // Rotate 90° left + Button { + rotate90Left() + } label: { + Image(systemName: "rotate.left") + .foregroundStyle(.white) + } + + // Reset all transforms + Button { + resetTransform() + } label: { + Image(systemName: "arrow.counterclockwise") + .foregroundStyle(.white) + } + + // Rotate 90° right + Button { + rotate90Right() + } label: { + Image(systemName: "rotate.right") + .foregroundStyle(.white) + } } } ToolbarItem(placement: .confirmationAction) { @@ -153,16 +176,28 @@ struct PhotoCropperSheet: View { } } - private var magnificationGesture: some Gesture { - MagnificationGesture() - .onChanged { value in - let newScale = lastScale * value + private var combinedPinchRotateGesture: some Gesture { + SimultaneousGesture( + MagnificationGesture(), + RotationGesture() + ) + .onChanged { value in + // Handle pinch (magnification) + if let magnification = value.first { + let newScale = lastScale * magnification // Limit scale between 0.5x and 5x scale = min(max(newScale, 0.5), 5.0) } - .onEnded { _ in - lastScale = scale + + // Handle rotation + if let rotationValue = value.second { + rotation = lastRotation + rotationValue } + } + .onEnded { _ in + lastScale = scale + lastRotation = rotation + } } // MARK: - Actions @@ -173,6 +208,21 @@ struct PhotoCropperSheet: View { lastScale = 1.0 offset = .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 - 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 // Guard against zero container size @@ -321,6 +375,44 @@ struct PhotoCropperSheet: View { 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