BusinessCard/BusinessCard/Views/Sheets/PhotoCropperSheet.swift

488 lines
17 KiB
Swift

import SwiftUI
import Bedrock
/// 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,
aspectRatio: CropAspectRatio = .square,
shouldDismissOnComplete: Bool = true,
onComplete: @escaping (Data?) -> Void
) {
self.imageData = imageData
self.aspectRatio = aspectRatio
self.shouldDismissOnComplete = shouldDismissOnComplete
self.onComplete = onComplete
}
@State private var scale: CGFloat = 1.0
@State private var lastScale: CGFloat = 1.0
@State private var offset: CGSize = .zero
@State private var lastOffset: CGSize = .zero
@State private var rotation: Angle = .zero
@State private var lastRotation: Angle = .zero
@State private var fixedRotation: Angle = .zero // For 90° toolbar rotations
@State private var containerSize: CGSize = .zero
// Horizontal padding for the crop area
private let horizontalPadding: CGFloat = 20
private var cropWidth: CGFloat {
// Use almost full width for better cropping experience
max(100, containerSize.width - (horizontalPadding * 2))
}
private var cropHeight: CGFloat {
cropWidth / aspectRatio.ratio
}
private var cropSize: CGSize {
// Guard against zero/invalid sizes
guard containerSize.width > 0, containerSize.height > 0 else {
return CGSize(width: 280, height: 280 / aspectRatio.ratio)
}
return CGSize(width: cropWidth, height: cropHeight)
}
private var uiImage: UIImage? {
UIImage(data: imageData)
}
var body: some View {
NavigationStack {
GeometryReader { geometry in
ZStack {
// Dark background
Color.black.ignoresSafeArea()
// Image with gestures
if let uiImage {
Image(uiImage: uiImage)
.resizable()
.scaledToFill()
.rotationEffect(rotation + fixedRotation)
.scaleEffect(scale)
.offset(offset)
.gesture(dragGesture)
.gesture(combinedPinchRotateGesture)
.frame(width: geometry.size.width, height: geometry.size.height)
.clipped()
}
// Overlay with crop area cutout
CropOverlay(cropSize: cropSize, containerSize: geometry.size)
// Grid lines inside crop area
CropGridLines(cropSize: cropSize)
.position(x: geometry.size.width / 2, y: geometry.size.height / 2)
}
.onAppear {
containerSize = geometry.size
}
.onChange(of: geometry.size) { _, newSize in
containerSize = newSize
}
}
.navigationBarTitleDisplayMode(.inline)
.toolbarBackground(.hidden, for: .navigationBar)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(String.localized("Cancel")) {
onComplete(nil)
if shouldDismissOnComplete {
dismiss()
}
}
.foregroundStyle(.white)
}
ToolbarItem(placement: .principal) {
HStack(spacing: Design.Spacing.xLarge) {
// Rotate 90° left
Button {
rotate90Left()
} label: {
Image(systemName: "rotate.left")
.foregroundStyle(.white)
}
// Reset all transforms
Button {
resetTransform()
} label: {
Image(systemName: "arrow.counterclockwise")
.foregroundStyle(.white)
}
// Rotate 90° right
Button {
rotate90Right()
} label: {
Image(systemName: "rotate.right")
.foregroundStyle(.white)
}
}
}
ToolbarItem(placement: .confirmationAction) {
Button(String.localized("Done")) {
cropAndSave()
}
.bold()
.foregroundStyle(.white)
}
}
}
.preferredColorScheme(.dark)
}
// MARK: - Gestures
private var dragGesture: some Gesture {
DragGesture()
.onChanged { value in
offset = CGSize(
width: lastOffset.width + value.translation.width,
height: lastOffset.height + value.translation.height
)
}
.onEnded { _ in
lastOffset = offset
}
}
private var combinedPinchRotateGesture: some Gesture {
SimultaneousGesture(
MagnificationGesture(),
RotationGesture()
)
.onChanged { value in
// Handle pinch (magnification)
if let magnification = value.first {
let newScale = lastScale * magnification
// Limit scale between 0.5x and 5x
scale = min(max(newScale, 0.5), 5.0)
}
// Handle rotation
if let rotationValue = value.second {
rotation = lastRotation + rotationValue
}
}
.onEnded { _ in
lastScale = scale
lastRotation = rotation
}
}
// MARK: - Actions
private func resetTransform() {
withAnimation(.spring(duration: Design.Animation.springDuration)) {
scale = 1.0
lastScale = 1.0
offset = .zero
lastOffset = .zero
rotation = .zero
lastRotation = .zero
fixedRotation = .zero
}
}
private func rotate90Left() {
withAnimation(.spring(duration: Design.Animation.springDuration)) {
fixedRotation -= .degrees(90)
}
}
private func rotate90Right() {
withAnimation(.spring(duration: Design.Animation.springDuration)) {
fixedRotation += .degrees(90)
}
}
private func cropAndSave() {
guard let uiImage else {
onComplete(nil)
if shouldDismissOnComplete {
dismiss()
}
return
}
// First, normalize the image orientation so we're working with correctly oriented pixels
let orientedImage = normalizeImageOrientation(uiImage)
// Apply total rotation (gesture rotation + fixed rotation)
let totalRotation = rotation + fixedRotation
let normalizedImage = rotateImage(orientedImage, by: totalRotation)
let imageSize = normalizedImage.size
// Guard against zero container size
guard containerSize.width > 0, containerSize.height > 0 else {
onComplete(nil)
if shouldDismissOnComplete {
dismiss()
}
return
}
// The image is displayed with scaledToFill in the container
// Calculate the display size of the image (before user scale)
let containerAspect = containerSize.width / containerSize.height
let imageAspect = imageSize.width / imageSize.height
let baseDisplaySize: CGSize
if imageAspect > containerAspect {
// Image is wider - height fills container
baseDisplaySize = CGSize(
width: containerSize.height * imageAspect,
height: containerSize.height
)
} else {
// Image is taller - width fills container
baseDisplaySize = CGSize(
width: containerSize.width,
height: containerSize.width / imageAspect
)
}
// Apply the user's scale to get the actual display size
let scaledDisplaySize = CGSize(
width: baseDisplaySize.width * scale,
height: baseDisplaySize.height * scale
)
// Calculate the ratio between image pixels and display points
let displayToImageRatioX = imageSize.width / scaledDisplaySize.width
let displayToImageRatioY = imageSize.height / scaledDisplaySize.height
// 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 area center: (containerSize.width/2, containerSize.height/2)
// 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
// Convert to image pixel coordinates
// Image center in pixels is (imageSize.width/2, imageSize.height/2)
let cropCenterInImageX = (imageSize.width / 2) + (cropCenterRelativeToImageCenterX * displayToImageRatioX)
let cropCenterInImageY = (imageSize.height / 2) + (cropCenterRelativeToImageCenterY * displayToImageRatioY)
// 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 - (cropWidthInImagePixels / 2)
let cropOriginY = cropCenterInImageY - (cropHeightInImagePixels / 2)
// Clamp to image bounds
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,
y: clampedY,
width: clampedWidth,
height: clampedHeight
)
// Crop the normalized image
guard let cgImage = normalizedImage.cgImage,
let croppedCGImage = cgImage.cropping(to: cropRect) else {
onComplete(nil)
if shouldDismissOnComplete {
dismiss()
}
return
}
let croppedUIImage = UIImage(cgImage: croppedCGImage)
// 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
let renderer = UIGraphicsImageRenderer(size: outputSize, format: format)
let resizedImage = renderer.image { _ in
croppedUIImage.draw(in: CGRect(origin: .zero, size: outputSize))
}
if let jpegData = resizedImage.jpegData(compressionQuality: 0.85) {
onComplete(jpegData)
} else {
onComplete(nil)
}
if shouldDismissOnComplete {
dismiss()
}
}
/// Normalizes image orientation by redrawing with correct orientation applied
private func normalizeImageOrientation(_ image: UIImage) -> UIImage {
guard image.imageOrientation != .up else { return image }
let format = UIGraphicsImageRendererFormat()
format.scale = image.scale
let renderer = UIGraphicsImageRenderer(size: image.size, format: format)
return renderer.image { _ in
image.draw(at: .zero)
}
}
/// Rotates an image by the specified angle
private func rotateImage(_ image: UIImage, by angle: Angle) -> UIImage {
// If no rotation needed, return original
guard abs(angle.radians) > 0.001 else { return image }
let radians = CGFloat(angle.radians)
// Calculate the size of the rotated image
let rotatedRect = CGRect(origin: .zero, size: image.size)
.applying(CGAffineTransform(rotationAngle: radians))
let rotatedSize = CGSize(
width: abs(rotatedRect.width),
height: abs(rotatedRect.height)
)
let format = UIGraphicsImageRendererFormat()
format.scale = 1 // Use 1:1 scale for output
let renderer = UIGraphicsImageRenderer(size: rotatedSize, format: format)
return renderer.image { context in
let cgContext = context.cgContext
// Move to center
cgContext.translateBy(x: rotatedSize.width / 2, y: rotatedSize.height / 2)
// Rotate
cgContext.rotate(by: radians)
// Draw image centered
image.draw(in: CGRect(
x: -image.size.width / 2,
y: -image.size.height / 2,
width: image.size.width,
height: image.size.height
))
}
}
}
// MARK: - Crop Overlay
private struct CropOverlay: View {
let cropSize: CGSize
let containerSize: CGSize
var body: some View {
ZStack {
// Semi-transparent overlay
Rectangle()
.fill(Color.black.opacity(Design.Opacity.accent))
// Clear rectangle in center (can be square or banner)
Rectangle()
.fill(Color.clear)
.frame(width: cropSize.width, height: cropSize.height)
.blendMode(.destinationOut)
}
.compositingGroup()
.allowsHitTesting(false)
// Border around crop area
Rectangle()
.stroke(Color.white, lineWidth: Design.LineWidth.thin)
.frame(width: cropSize.width, height: cropSize.height)
.allowsHitTesting(false)
}
}
// MARK: - Crop Grid Lines
private struct CropGridLines: View {
let cropSize: CGSize
var body: some View {
ZStack {
// Vertical lines (rule of thirds)
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.height)
}
}
// Horizontal lines (rule of thirds)
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.width, height: Design.LineWidth.thin)
}
}
}
.frame(width: cropSize.width, height: cropSize.height)
.allowsHitTesting(false)
}
}
// MARK: - Preview
#Preview {
// Create a sample image for preview
let sampleImage = UIImage(systemName: "person.fill")!
let sampleData = sampleImage.pngData()!
return PhotoCropperSheet(imageData: sampleData) { data in
print(data != nil ? "Saved" : "Cancelled")
}
}