From 8829a5a912d57c097e0b7aa8bc69a3e468180a05 Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Fri, 9 Jan 2026 09:30:09 -0600 Subject: [PATCH] Signed-off-by: Matt Bruce --- .../Views/Sheets/PhotoCropperSheet.swift | 102 +++++++++++++----- 1 file changed, 75 insertions(+), 27 deletions(-) diff --git a/BusinessCard/Views/Sheets/PhotoCropperSheet.swift b/BusinessCard/Views/Sheets/PhotoCropperSheet.swift index d57f041..e5117eb 100644 --- a/BusinessCard/Views/Sheets/PhotoCropperSheet.swift +++ b/BusinessCard/Views/Sheets/PhotoCropperSheet.swift @@ -1,17 +1,43 @@ import SwiftUI import Bedrock -/// A sheet that allows the user to crop an image to a square. +/// 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, shouldDismissOnComplete: Bool = true, onComplete: @escaping (Data?) -> Void) { + 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 } @@ -22,8 +48,20 @@ struct PhotoCropperSheet: View { @State private var lastOffset: CGSize = .zero @State private var containerSize: CGSize = .zero - // Crop area size (square) - private let cropSize: CGFloat = 280 + // Base crop dimension - will be adjusted by aspect ratio + private let baseCropWidth: CGFloat = 300 + + private var cropWidth: CGFloat { + min(baseCropWidth, containerSize.width - 40) // Leave some padding + } + + private var cropHeight: CGFloat { + cropWidth / aspectRatio.ratio + } + + private var cropSize: CGSize { + CGSize(width: cropWidth, height: cropHeight) + } private var uiImage: UIImage? { UIImage(data: imageData) @@ -185,15 +223,15 @@ struct PhotoCropperSheet: View { let displayToImageRatioX = imageSize.width / scaledDisplaySize.width let displayToImageRatioY = imageSize.height / scaledDisplaySize.height - // The crop square is centered in the container + // 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 square center: (containerSize.width/2, containerSize.height/2) + // - Crop area center: (containerSize.width/2, containerSize.height/2) - // The crop square's center relative to the image's center (in display coords): + // 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 @@ -203,18 +241,19 @@ struct PhotoCropperSheet: View { let cropCenterInImageX = (imageSize.width / 2) + (cropCenterRelativeToImageCenterX * displayToImageRatioX) let cropCenterInImageY = (imageSize.height / 2) + (cropCenterRelativeToImageCenterY * displayToImageRatioY) - // Crop size in image pixels - let cropSizeInImagePixels = cropSize * displayToImageRatioX + // 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 - (cropSizeInImagePixels / 2) - let cropOriginY = cropCenterInImageY - (cropSizeInImagePixels / 2) + let cropOriginX = cropCenterInImageX - (cropWidthInImagePixels / 2) + let cropOriginY = cropCenterInImageY - (cropHeightInImagePixels / 2) // Clamp to image bounds - let clampedX = max(0, min(cropOriginX, imageSize.width - cropSizeInImagePixels)) - let clampedY = max(0, min(cropOriginY, imageSize.height - cropSizeInImagePixels)) - let clampedWidth = min(cropSizeInImagePixels, imageSize.width - clampedX) - let clampedHeight = min(cropSizeInImagePixels, imageSize.height - clampedY) + 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, @@ -235,8 +274,17 @@ struct PhotoCropperSheet: View { let croppedUIImage = UIImage(cgImage: croppedCGImage) - // Resize to a consistent output size (512x512 for profile photos) - let outputSize = CGSize(width: 512, height: 512) + // 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 @@ -273,7 +321,7 @@ struct PhotoCropperSheet: View { // MARK: - Crop Overlay private struct CropOverlay: View { - let cropSize: CGFloat + let cropSize: CGSize let containerSize: CGSize var body: some View { @@ -282,10 +330,10 @@ private struct CropOverlay: View { Rectangle() .fill(Color.black.opacity(Design.Opacity.accent)) - // Clear square in center + // Clear rectangle in center (can be square or banner) Rectangle() .fill(Color.clear) - .frame(width: cropSize, height: cropSize) + .frame(width: cropSize.width, height: cropSize.height) .blendMode(.destinationOut) } .compositingGroup() @@ -294,7 +342,7 @@ private struct CropOverlay: View { // Border around crop area Rectangle() .stroke(Color.white, lineWidth: Design.LineWidth.thin) - .frame(width: cropSize, height: cropSize) + .frame(width: cropSize.width, height: cropSize.height) .allowsHitTesting(false) } } @@ -302,29 +350,29 @@ private struct CropOverlay: View { // MARK: - Crop Grid Lines private struct CropGridLines: View { - let cropSize: CGFloat + let cropSize: CGSize var body: some View { ZStack { // Vertical lines (rule of thirds) - HStack(spacing: cropSize / 3 - Design.LineWidth.thin) { + 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) + .frame(width: Design.LineWidth.thin, height: cropSize.height) } } // Horizontal lines (rule of thirds) - VStack(spacing: cropSize / 3 - Design.LineWidth.thin) { + 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, height: Design.LineWidth.thin) + .frame(width: cropSize.width, height: Design.LineWidth.thin) } } } - .frame(width: cropSize, height: cropSize) + .frame(width: cropSize.width, height: cropSize.height) .allowsHitTesting(false) } }