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