import SwiftUI import Bedrock /// Aspect ratio options for the photo cropper enum CropAspectRatio { case square // 1:1 for profile photos case banner // Wide ratio for cover/banner photos (roughly 2.3:1) case custom(CGFloat) // Custom width:height ratio var ratio: CGFloat { switch self { case .square: return 1.0 case .banner: return 2.3 // Width is 2.3x height case .custom(let ratio): return ratio } } } /// A sheet that allows the user to crop an image. /// Supports pinch-to-zoom and drag gestures for positioning. /// Use `aspectRatio` to specify square, banner, or custom crop shapes. struct PhotoCropperSheet: View { @Environment(\.dismiss) private var dismiss let imageData: Data let aspectRatio: CropAspectRatio let onComplete: (Data?) -> Void // nil = cancelled, Data = saved let shouldDismissOnComplete: Bool init( imageData: Data, aspectRatio: CropAspectRatio = .square, shouldDismissOnComplete: Bool = true, onComplete: @escaping (Data?) -> Void ) { self.imageData = imageData self.aspectRatio = aspectRatio self.shouldDismissOnComplete = shouldDismissOnComplete self.onComplete = onComplete } @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 @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 private let horizontalPadding: CGFloat = 20 private var cropWidth: CGFloat { // Use almost full width for better cropping experience max(100, containerSize.width - (horizontalPadding * 2)) } private var cropHeight: CGFloat { cropWidth / aspectRatio.ratio } private var cropSize: CGSize { // Guard against zero/invalid sizes guard containerSize.width > 0, containerSize.height > 0 else { return CGSize(width: 280, height: 280 / aspectRatio.ratio) } return CGSize(width: cropWidth, height: cropHeight) } private var uiImage: UIImage? { UIImage(data: imageData) } var body: some View { NavigationStack { GeometryReader { geometry in ZStack { // Dark background Color.black.ignoresSafeArea() // Image with gestures if let uiImage { Image(uiImage: uiImage) .resizable() .scaledToFill() .rotationEffect(rotation + fixedRotation) .scaleEffect(scale) .offset(offset) .gesture(dragGesture) .gesture(combinedPinchRotateGesture) .frame(width: geometry.size.width, height: geometry.size.height) .clipped() } // Overlay with crop area cutout CropOverlay(cropSize: cropSize, containerSize: geometry.size) // Grid lines inside crop area CropGridLines(cropSize: cropSize) .position(x: geometry.size.width / 2, y: geometry.size.height / 2) } .onAppear { containerSize = geometry.size } .onChange(of: geometry.size) { _, newSize in containerSize = newSize } } .navigationBarTitleDisplayMode(.inline) .toolbarBackground(.hidden, for: .navigationBar) .toolbar { ToolbarItem(placement: .cancellationAction) { Button(String.localized("Cancel")) { onComplete(nil) if shouldDismissOnComplete { dismiss() } } .foregroundStyle(.white) } ToolbarItem(placement: .principal) { 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) { Button(String.localized("Done")) { cropAndSave() } .bold() .foregroundStyle(.white) } } } .preferredColorScheme(.dark) } // MARK: - Gestures private var dragGesture: some Gesture { DragGesture() .onChanged { value in offset = CGSize( width: lastOffset.width + value.translation.width, height: lastOffset.height + value.translation.height ) } .onEnded { _ in lastOffset = offset } } 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) } // Handle rotation if let rotationValue = value.second { rotation = lastRotation + rotationValue } } .onEnded { _ in lastScale = scale lastRotation = rotation } } // MARK: - Actions private func resetTransform() { withAnimation(.spring(duration: Design.Animation.springDuration)) { scale = 1.0 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) } } private func cropAndSave() { guard let uiImage else { onComplete(nil) if shouldDismissOnComplete { dismiss() } return } // First, normalize the image orientation so we're working with correctly oriented pixels 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 guard containerSize.width > 0, containerSize.height > 0 else { onComplete(nil) if shouldDismissOnComplete { dismiss() } return } // The image is displayed with scaledToFill in the container // Calculate the display size of the image (before user scale) let containerAspect = containerSize.width / containerSize.height let imageAspect = imageSize.width / imageSize.height let baseDisplaySize: CGSize if imageAspect > containerAspect { // Image is wider - height fills container baseDisplaySize = CGSize( width: containerSize.height * imageAspect, height: containerSize.height ) } else { // Image is taller - width fills container baseDisplaySize = CGSize( width: containerSize.width, height: containerSize.width / imageAspect ) } // Apply the user's scale to get the actual display size let scaledDisplaySize = CGSize( width: baseDisplaySize.width * scale, height: baseDisplaySize.height * scale ) // Calculate the ratio between image pixels and display points let displayToImageRatioX = imageSize.width / scaledDisplaySize.width let displayToImageRatioY = imageSize.height / scaledDisplaySize.height // The crop area is centered in the container // The image is also centered, but shifted by the user's offset // In display coordinates: // - Container center: (containerSize.width/2, containerSize.height/2) // - Image center after offset: (containerSize.width/2 + offset.width, containerSize.height/2 + offset.height) // - Crop area center: (containerSize.width/2, containerSize.height/2) // The crop area's center relative to the image's center (in display coords): // cropCenterInImage = cropCenter - imageCenter = -offset let cropCenterRelativeToImageCenterX = -offset.width let cropCenterRelativeToImageCenterY = -offset.height // Convert to image pixel coordinates // Image center in pixels is (imageSize.width/2, imageSize.height/2) let cropCenterInImageX = (imageSize.width / 2) + (cropCenterRelativeToImageCenterX * displayToImageRatioX) let cropCenterInImageY = (imageSize.height / 2) + (cropCenterRelativeToImageCenterY * displayToImageRatioY) // Crop size in image pixels (width and height can be different for banners) let cropWidthInImagePixels = cropSize.width * displayToImageRatioX let cropHeightInImagePixels = cropSize.height * displayToImageRatioY // Calculate crop rect origin (top-left corner) let cropOriginX = cropCenterInImageX - (cropWidthInImagePixels / 2) let cropOriginY = cropCenterInImageY - (cropHeightInImagePixels / 2) // Clamp to image bounds let clampedX = max(0, min(cropOriginX, imageSize.width - cropWidthInImagePixels)) let clampedY = max(0, min(cropOriginY, imageSize.height - cropHeightInImagePixels)) let clampedWidth = min(cropWidthInImagePixels, imageSize.width - clampedX) let clampedHeight = min(cropHeightInImagePixels, imageSize.height - clampedY) let cropRect = CGRect( x: clampedX, y: clampedY, width: clampedWidth, height: clampedHeight ) // Crop the normalized image guard let cgImage = normalizedImage.cgImage, let croppedCGImage = cgImage.cropping(to: cropRect) else { onComplete(nil) if shouldDismissOnComplete { dismiss() } return } let croppedUIImage = UIImage(cgImage: croppedCGImage) // Resize to a consistent output size based on aspect ratio let outputSize: CGSize switch aspectRatio { case .square: outputSize = CGSize(width: 512, height: 512) case .banner: outputSize = CGSize(width: 1024, height: 445) // ~2.3:1 ratio case .custom(let ratio): outputSize = CGSize(width: 1024, height: 1024 / ratio) } let format = UIGraphicsImageRendererFormat() format.scale = 1 let renderer = UIGraphicsImageRenderer(size: outputSize, format: format) let resizedImage = renderer.image { _ in croppedUIImage.draw(in: CGRect(origin: .zero, size: outputSize)) } if let jpegData = resizedImage.jpegData(compressionQuality: 0.85) { onComplete(jpegData) } else { onComplete(nil) } if shouldDismissOnComplete { dismiss() } } /// Normalizes image orientation by redrawing with correct orientation applied private func normalizeImageOrientation(_ image: UIImage) -> UIImage { guard image.imageOrientation != .up else { return image } let format = UIGraphicsImageRendererFormat() format.scale = image.scale let renderer = UIGraphicsImageRenderer(size: image.size, format: format) return renderer.image { _ in 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 private struct CropOverlay: View { let cropSize: CGSize let containerSize: CGSize var body: some View { ZStack { // Semi-transparent overlay Rectangle() .fill(Color.black.opacity(Design.Opacity.accent)) // Clear rectangle in center (can be square or banner) Rectangle() .fill(Color.clear) .frame(width: cropSize.width, height: cropSize.height) .blendMode(.destinationOut) } .compositingGroup() .allowsHitTesting(false) // Border around crop area Rectangle() .stroke(Color.white, lineWidth: Design.LineWidth.thin) .frame(width: cropSize.width, height: cropSize.height) .allowsHitTesting(false) } } // MARK: - Crop Grid Lines private struct CropGridLines: View { let cropSize: CGSize var body: some View { ZStack { // Vertical lines (rule of thirds) HStack(spacing: cropSize.width / 3 - Design.LineWidth.thin) { ForEach(0..<2, id: \.self) { _ in Rectangle() .fill(Color.white.opacity(Design.Opacity.light)) .frame(width: Design.LineWidth.thin, height: cropSize.height) } } // Horizontal lines (rule of thirds) VStack(spacing: cropSize.height / 3 - Design.LineWidth.thin) { ForEach(0..<2, id: \.self) { _ in Rectangle() .fill(Color.white.opacity(Design.Opacity.light)) .frame(width: cropSize.width, height: Design.LineWidth.thin) } } } .frame(width: cropSize.width, height: cropSize.height) .allowsHitTesting(false) } } // MARK: - Preview #Preview { // Create a sample image for preview let sampleImage = UIImage(systemName: "person.fill")! let sampleData = sampleImage.pngData()! return PhotoCropperSheet(imageData: sampleData) { data in print(data != nil ? "Saved" : "Cancelled") } }