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

This commit is contained in:
Matt Bruce 2026-01-09 09:30:09 -06:00
parent 4a9ac58b7a
commit 8829a5a912

View File

@ -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)
}
}