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 SwiftUI
import Bedrock 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. /// Supports pinch-to-zoom and drag gestures for positioning.
/// Use `aspectRatio` to specify square, banner, or custom crop shapes.
struct PhotoCropperSheet: View { struct PhotoCropperSheet: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
let imageData: Data let imageData: Data
let aspectRatio: CropAspectRatio
let onComplete: (Data?) -> Void // nil = cancelled, Data = saved let onComplete: (Data?) -> Void // nil = cancelled, Data = saved
let shouldDismissOnComplete: Bool 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.imageData = imageData
self.aspectRatio = aspectRatio
self.shouldDismissOnComplete = shouldDismissOnComplete self.shouldDismissOnComplete = shouldDismissOnComplete
self.onComplete = onComplete self.onComplete = onComplete
} }
@ -22,8 +48,20 @@ struct PhotoCropperSheet: View {
@State private var lastOffset: CGSize = .zero @State private var lastOffset: CGSize = .zero
@State private var containerSize: CGSize = .zero @State private var containerSize: CGSize = .zero
// Crop area size (square) // Base crop dimension - will be adjusted by aspect ratio
private let cropSize: CGFloat = 280 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? { private var uiImage: UIImage? {
UIImage(data: imageData) UIImage(data: imageData)
@ -185,15 +223,15 @@ struct PhotoCropperSheet: View {
let displayToImageRatioX = imageSize.width / scaledDisplaySize.width let displayToImageRatioX = imageSize.width / scaledDisplaySize.width
let displayToImageRatioY = imageSize.height / scaledDisplaySize.height 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 // The image is also centered, but shifted by the user's offset
// In display coordinates: // In display coordinates:
// - Container center: (containerSize.width/2, containerSize.height/2) // - Container center: (containerSize.width/2, containerSize.height/2)
// - Image center after offset: (containerSize.width/2 + offset.width, containerSize.height/2 + offset.height) // - 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 // cropCenterInImage = cropCenter - imageCenter = -offset
let cropCenterRelativeToImageCenterX = -offset.width let cropCenterRelativeToImageCenterX = -offset.width
let cropCenterRelativeToImageCenterY = -offset.height let cropCenterRelativeToImageCenterY = -offset.height
@ -203,18 +241,19 @@ struct PhotoCropperSheet: View {
let cropCenterInImageX = (imageSize.width / 2) + (cropCenterRelativeToImageCenterX * displayToImageRatioX) let cropCenterInImageX = (imageSize.width / 2) + (cropCenterRelativeToImageCenterX * displayToImageRatioX)
let cropCenterInImageY = (imageSize.height / 2) + (cropCenterRelativeToImageCenterY * displayToImageRatioY) let cropCenterInImageY = (imageSize.height / 2) + (cropCenterRelativeToImageCenterY * displayToImageRatioY)
// Crop size in image pixels // Crop size in image pixels (width and height can be different for banners)
let cropSizeInImagePixels = cropSize * displayToImageRatioX let cropWidthInImagePixels = cropSize.width * displayToImageRatioX
let cropHeightInImagePixels = cropSize.height * displayToImageRatioY
// Calculate crop rect origin (top-left corner) // Calculate crop rect origin (top-left corner)
let cropOriginX = cropCenterInImageX - (cropSizeInImagePixels / 2) let cropOriginX = cropCenterInImageX - (cropWidthInImagePixels / 2)
let cropOriginY = cropCenterInImageY - (cropSizeInImagePixels / 2) let cropOriginY = cropCenterInImageY - (cropHeightInImagePixels / 2)
// Clamp to image bounds // Clamp to image bounds
let clampedX = max(0, min(cropOriginX, imageSize.width - cropSizeInImagePixels)) let clampedX = max(0, min(cropOriginX, imageSize.width - cropWidthInImagePixels))
let clampedY = max(0, min(cropOriginY, imageSize.height - cropSizeInImagePixels)) let clampedY = max(0, min(cropOriginY, imageSize.height - cropHeightInImagePixels))
let clampedWidth = min(cropSizeInImagePixels, imageSize.width - clampedX) let clampedWidth = min(cropWidthInImagePixels, imageSize.width - clampedX)
let clampedHeight = min(cropSizeInImagePixels, imageSize.height - clampedY) let clampedHeight = min(cropHeightInImagePixels, imageSize.height - clampedY)
let cropRect = CGRect( let cropRect = CGRect(
x: clampedX, x: clampedX,
@ -235,8 +274,17 @@ struct PhotoCropperSheet: View {
let croppedUIImage = UIImage(cgImage: croppedCGImage) let croppedUIImage = UIImage(cgImage: croppedCGImage)
// Resize to a consistent output size (512x512 for profile photos) // Resize to a consistent output size based on aspect ratio
let outputSize = CGSize(width: 512, height: 512) 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() let format = UIGraphicsImageRendererFormat()
format.scale = 1 format.scale = 1
@ -273,7 +321,7 @@ struct PhotoCropperSheet: View {
// MARK: - Crop Overlay // MARK: - Crop Overlay
private struct CropOverlay: View { private struct CropOverlay: View {
let cropSize: CGFloat let cropSize: CGSize
let containerSize: CGSize let containerSize: CGSize
var body: some View { var body: some View {
@ -282,10 +330,10 @@ private struct CropOverlay: View {
Rectangle() Rectangle()
.fill(Color.black.opacity(Design.Opacity.accent)) .fill(Color.black.opacity(Design.Opacity.accent))
// Clear square in center // Clear rectangle in center (can be square or banner)
Rectangle() Rectangle()
.fill(Color.clear) .fill(Color.clear)
.frame(width: cropSize, height: cropSize) .frame(width: cropSize.width, height: cropSize.height)
.blendMode(.destinationOut) .blendMode(.destinationOut)
} }
.compositingGroup() .compositingGroup()
@ -294,7 +342,7 @@ private struct CropOverlay: View {
// Border around crop area // Border around crop area
Rectangle() Rectangle()
.stroke(Color.white, lineWidth: Design.LineWidth.thin) .stroke(Color.white, lineWidth: Design.LineWidth.thin)
.frame(width: cropSize, height: cropSize) .frame(width: cropSize.width, height: cropSize.height)
.allowsHitTesting(false) .allowsHitTesting(false)
} }
} }
@ -302,29 +350,29 @@ private struct CropOverlay: View {
// MARK: - Crop Grid Lines // MARK: - Crop Grid Lines
private struct CropGridLines: View { private struct CropGridLines: View {
let cropSize: CGFloat let cropSize: CGSize
var body: some View { var body: some View {
ZStack { ZStack {
// Vertical lines (rule of thirds) // 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 ForEach(0..<2, id: \.self) { _ in
Rectangle() Rectangle()
.fill(Color.white.opacity(Design.Opacity.light)) .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) // 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 ForEach(0..<2, id: \.self) { _ in
Rectangle() Rectangle()
.fill(Color.white.opacity(Design.Opacity.light)) .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) .allowsHitTesting(false)
} }
} }