488 lines
17 KiB
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")
|
|
}
|
|
}
|