Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2026-01-09 10:28:01 -06:00
parent 05aedadfc3
commit 8c40e638e5
2 changed files with 125 additions and 14 deletions

View File

@ -25,12 +25,18 @@ struct ImageEditorFlow: View {
imageType.cropAspectRatio imageType.cropAspectRatio
} }
/// Only allow aspect ratio selection for logos
private var allowAspectRatioSelection: Bool {
imageType == .logo
}
var body: some View { var body: some View {
// Source picker is the base content of this sheet // Source picker is the base content of this sheet
sourcePickerView sourcePickerView
.fullScreenCover(isPresented: $showingFullScreenPicker) { .fullScreenCover(isPresented: $showingFullScreenPicker) {
PhotoPickerFlow( PhotoPickerFlow(
aspectRatio: aspectRatio, aspectRatio: aspectRatio,
allowAspectRatioSelection: allowAspectRatioSelection,
onComplete: { imageData in onComplete: { imageData in
showingFullScreenPicker = false showingFullScreenPicker = false
if let imageData { if let imageData {
@ -43,6 +49,7 @@ struct ImageEditorFlow: View {
.fullScreenCover(isPresented: $showingFullScreenCamera) { .fullScreenCover(isPresented: $showingFullScreenCamera) {
CameraFlow( CameraFlow(
aspectRatio: aspectRatio, aspectRatio: aspectRatio,
allowAspectRatioSelection: allowAspectRatioSelection,
onComplete: { imageData in onComplete: { imageData in
showingFullScreenCamera = false showingFullScreenCamera = false
if let imageData { if let imageData {
@ -121,6 +128,7 @@ struct ImageEditorFlow: View {
private struct PhotoPickerFlow: View { private struct PhotoPickerFlow: View {
let aspectRatio: CropAspectRatio let aspectRatio: CropAspectRatio
let allowAspectRatioSelection: Bool
let onComplete: (Data?) -> Void let onComplete: (Data?) -> Void
@State private var selectedPhotoItem: PhotosPickerItem? @State private var selectedPhotoItem: PhotosPickerItem?
@ -163,6 +171,7 @@ private struct PhotoPickerFlow: View {
PhotoCropperSheet( PhotoCropperSheet(
imageData: imageData, imageData: imageData,
aspectRatio: aspectRatio, aspectRatio: aspectRatio,
allowAspectRatioSelection: allowAspectRatioSelection,
shouldDismissOnComplete: false shouldDismissOnComplete: false
) { croppedData in ) { croppedData in
if let croppedData { if let croppedData {
@ -186,6 +195,7 @@ private struct PhotoPickerFlow: View {
private struct CameraFlow: View { private struct CameraFlow: View {
let aspectRatio: CropAspectRatio let aspectRatio: CropAspectRatio
let allowAspectRatioSelection: Bool
let onComplete: (Data?) -> Void let onComplete: (Data?) -> Void
@State private var capturedImageData: Data? @State private var capturedImageData: Data?
@ -215,6 +225,7 @@ private struct CameraFlow: View {
PhotoCropperSheet( PhotoCropperSheet(
imageData: imageData, imageData: imageData,
aspectRatio: aspectRatio, aspectRatio: aspectRatio,
allowAspectRatioSelection: allowAspectRatioSelection,
shouldDismissOnComplete: false shouldDismissOnComplete: false
) { croppedData in ) { croppedData in
if let croppedData { if let croppedData {

View File

@ -2,44 +2,94 @@ import SwiftUI
import Bedrock import Bedrock
/// Aspect ratio options for the photo cropper /// Aspect ratio options for the photo cropper
enum CropAspectRatio { enum CropAspectRatio: Identifiable, CaseIterable, Equatable {
case original // Use image's original aspect ratio
case square // 1:1 for profile photos case square // 1:1 for profile photos
case threeToTwo // 3:2
case fiveToThree // 5:3
case fourToThree // 4:3
case fiveToFour // 5:4
case sevenToFive // 7:5
case sixteenToNine // 16:9
case banner // Wide ratio for cover/banner photos (roughly 2.3:1) case banner // Wide ratio for cover/banner photos (roughly 2.3:1)
case custom(CGFloat) // Custom width:height ratio
var ratio: CGFloat { var id: String { displayName }
static var allCases: [CropAspectRatio] {
[.original, .square, .threeToTwo, .fiveToThree, .fourToThree, .fiveToFour, .sevenToFive, .sixteenToNine]
}
var displayName: String {
switch self { switch self {
case .original: return String.localized("Original")
case .square: return String.localized("Square")
case .threeToTwo: return "3:2"
case .fiveToThree: return "5:3"
case .fourToThree: return "4:3"
case .fiveToFour: return "5:4"
case .sevenToFive: return "7:5"
case .sixteenToNine: return "16:9"
case .banner: return String.localized("Banner")
}
}
/// Returns the ratio (width / height). For .original, pass the image size.
func ratio(for imageSize: CGSize? = nil) -> CGFloat {
switch self {
case .original:
guard let size = imageSize, size.height > 0 else { return 1.0 }
return size.width / size.height
case .square: case .square:
return 1.0 return 1.0
case .threeToTwo:
return 3.0 / 2.0
case .fiveToThree:
return 5.0 / 3.0
case .fourToThree:
return 4.0 / 3.0
case .fiveToFour:
return 5.0 / 4.0
case .sevenToFive:
return 7.0 / 5.0
case .sixteenToNine:
return 16.0 / 9.0
case .banner: case .banner:
return 2.3 // Width is 2.3x height return 2.3 // Width is 2.3x height
case .custom(let ratio):
return ratio
} }
} }
/// Simple ratio for backwards compatibility (doesn't handle .original properly)
var ratio: CGFloat {
ratio(for: nil)
}
} }
/// A sheet that allows the user to crop an image. /// 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. /// Use `aspectRatio` to specify square, banner, or custom crop shapes.
/// Set `allowAspectRatioSelection` to true to show aspect ratio picker (for logos).
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 initialAspectRatio: CropAspectRatio
let allowAspectRatioSelection: Bool
let onComplete: (Data?) -> Void // nil = cancelled, Data = saved let onComplete: (Data?) -> Void // nil = cancelled, Data = saved
let shouldDismissOnComplete: Bool let shouldDismissOnComplete: Bool
init( init(
imageData: Data, imageData: Data,
aspectRatio: CropAspectRatio = .square, aspectRatio: CropAspectRatio = .square,
allowAspectRatioSelection: Bool = false,
shouldDismissOnComplete: Bool = true, shouldDismissOnComplete: Bool = true,
onComplete: @escaping (Data?) -> Void onComplete: @escaping (Data?) -> Void
) { ) {
self.imageData = imageData self.imageData = imageData
self.aspectRatio = aspectRatio self.initialAspectRatio = aspectRatio
self.allowAspectRatioSelection = allowAspectRatioSelection
self.shouldDismissOnComplete = shouldDismissOnComplete self.shouldDismissOnComplete = shouldDismissOnComplete
self.onComplete = onComplete self.onComplete = onComplete
self._selectedAspectRatio = State(initialValue: aspectRatio)
} }
@State private var scale: CGFloat = 1.0 @State private var scale: CGFloat = 1.0
@ -50,25 +100,47 @@ struct PhotoCropperSheet: View {
@State private var lastRotation: Angle = .zero @State private var lastRotation: Angle = .zero
@State private var fixedRotation: Angle = .zero // For 90° toolbar rotations @State private var fixedRotation: Angle = .zero // For 90° toolbar rotations
@State private var containerSize: CGSize = .zero @State private var containerSize: CGSize = .zero
@State private var selectedAspectRatio: CropAspectRatio
@State private var showingAspectRatioPicker = false
// Horizontal padding for the crop area // Horizontal padding for the crop area
private let horizontalPadding: CGFloat = 20 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 { private var cropWidth: CGFloat {
// Use almost full width for better cropping experience // Use almost full width for better cropping experience
max(100, containerSize.width - (horizontalPadding * 2)) max(100, containerSize.width - (horizontalPadding * 2))
} }
private var cropHeight: CGFloat { private var cropHeight: CGFloat {
cropWidth / aspectRatio.ratio // 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 { private var cropSize: CGSize {
// Guard against zero/invalid sizes // Guard against zero/invalid sizes
guard containerSize.width > 0, containerSize.height > 0 else { guard containerSize.width > 0, containerSize.height > 0 else {
return CGSize(width: 280, height: 280 / aspectRatio.ratio) return CGSize(width: 280, height: 280 / effectiveRatio)
} }
return CGSize(width: cropWidth, height: cropHeight) return CGSize(width: adjustedCropWidth, height: cropHeight)
} }
private var uiImage: UIImage? { private var uiImage: UIImage? {
@ -140,6 +212,16 @@ struct PhotoCropperSheet: View {
.foregroundStyle(.white) .foregroundStyle(.white)
} }
// Aspect ratio picker (only for logo workflow)
if allowAspectRatioSelection {
Button {
showingAspectRatioPicker = true
} label: {
Image(systemName: "aspectratio")
.foregroundStyle(.white)
}
}
// Rotate 90° right // Rotate 90° right
Button { Button {
rotate90Right() rotate90Right()
@ -159,6 +241,20 @@ struct PhotoCropperSheet: View {
} }
} }
.preferredColorScheme(.dark) .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 // MARK: - Gestures
@ -335,13 +431,17 @@ struct PhotoCropperSheet: View {
// Resize to a consistent output size based on aspect ratio // Resize to a consistent output size based on aspect ratio
let outputSize: CGSize let outputSize: CGSize
switch aspectRatio { let baseWidth: CGFloat = 1024
let currentRatio = effectiveRatio
switch selectedAspectRatio {
case .square: case .square:
outputSize = CGSize(width: 512, height: 512) outputSize = CGSize(width: 512, height: 512)
case .banner: case .banner:
outputSize = CGSize(width: 1024, height: 445) // ~2.3:1 ratio outputSize = CGSize(width: baseWidth, height: baseWidth / 2.3)
case .custom(let ratio): default:
outputSize = CGSize(width: 1024, height: 1024 / ratio) // For all other ratios, use base width and calculate height
outputSize = CGSize(width: baseWidth, height: baseWidth / currentRatio)
} }
let format = UIGraphicsImageRendererFormat() let format = UIGraphicsImageRendererFormat()