Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
4a9ac58b7a
commit
8829a5a912
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user