BusinessCard/BusinessCard/Views/Features/Cards/Sheets/PhotoCropperSheet.swift

467 lines
17 KiB
Swift

import SwiftUI
import Bedrock
/// 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.
/// Set `allowAspectRatioSelection` to true to show aspect ratio picker (for logos).
struct PhotoCropperSheet: View {
@Environment(\.dismiss) private var dismiss
let imageData: Data
let initialAspectRatio: CropAspectRatio
let allowAspectRatioSelection: Bool
let onComplete: (Data?) -> Void // nil = cancelled, Data = saved
let shouldDismissOnComplete: Bool
init(
imageData: Data,
aspectRatio: CropAspectRatio = .square,
allowAspectRatioSelection: Bool = false,
shouldDismissOnComplete: Bool = true,
onComplete: @escaping (Data?) -> Void
) {
self.imageData = imageData
self.initialAspectRatio = aspectRatio
self.allowAspectRatioSelection = allowAspectRatioSelection
self.shouldDismissOnComplete = shouldDismissOnComplete
self.onComplete = onComplete
self._selectedAspectRatio = State(initialValue: aspectRatio)
}
@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
@State private var selectedAspectRatio: CropAspectRatio
@State private var showingAspectRatioPicker = false
// Horizontal padding for the crop area
private let horizontalPadding: CGFloat = 20
/// Current effective aspect ratio (accounting for image size for .original)
private var effectiveRatio: CGFloat {
selectedAspectRatio.ratio(for: uiImage?.size)
}
private var cropWidth: CGFloat {
// Use almost full width for better cropping experience
max(100, containerSize.width - (horizontalPadding * 2))
}
private var cropHeight: CGFloat {
// Calculate height based on aspect ratio
let height = cropWidth / effectiveRatio
// Ensure it fits within the container
if height > containerSize.height - 100 {
return containerSize.height - 100
}
return height
}
private var adjustedCropWidth: CGFloat {
// If height was constrained, recalculate width
let height = cropWidth / effectiveRatio
if height > containerSize.height - 100 {
return cropHeight * effectiveRatio
}
return cropWidth
}
private var cropSize: CGSize {
// Guard against zero/invalid sizes
guard containerSize.width > 0, containerSize.height > 0 else {
return CGSize(width: 280, height: 280 / effectiveRatio)
}
return CGSize(width: adjustedCropWidth, height: cropHeight)
}
private var uiImage: UIImage? {
UIImage(data: imageData)
}
var body: some View {
NavigationStack {
GeometryReader { geometry in
ZStack {
// Always black background (consistent regardless of system theme)
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(Color.black.opacity(Design.Opacity.heavy), for: .navigationBar)
.toolbarColorScheme(.dark, 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)
}
// Aspect ratio picker (only for logo workflow)
if allowAspectRatioSelection {
Button {
showingAspectRatioPicker = true
} label: {
Image(systemName: "aspectratio")
.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)
.confirmationDialog(
String.localized("Aspect Ratio"),
isPresented: $showingAspectRatioPicker,
titleVisibility: .visible
) {
ForEach(CropAspectRatio.allCases) { ratio in
Button(ratio.displayName) {
withAnimation(.spring(duration: Design.Animation.springDuration)) {
selectedAspectRatio = ratio
}
}
}
Button(String.localized("Cancel"), role: .cancel) { }
}
}
// 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
let baseWidth: CGFloat = 1024
let currentRatio = effectiveRatio
switch selectedAspectRatio {
case .square:
outputSize = CGSize(width: 512, height: 512)
case .banner:
outputSize = CGSize(width: baseWidth, height: baseWidth / 2.3)
default:
// For all other ratios, use base width and calculate height
outputSize = CGSize(width: baseWidth, height: baseWidth / currentRatio)
}
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: - Preview
#Preview {
// Create a sample image for preview
let sampleImage = UIImage(systemName: "person.fill")!
let sampleData = sampleImage.pngData()!
return PhotoCropperSheet(imageData: sampleData) { data in
Design.debugLog(data != nil ? "Saved" : "Cancelled")
}
}