// // ImagePicker.swift // SodaLive // // Created by klaus on 2023/08/09. // import SwiftUI import UIKit struct ImagePicker: UIViewControllerRepresentable { @Binding var isShowing: Bool @Binding var selectedImage: UIImage? let sourceType: UIImagePickerController.SourceType func makeUIViewController(context: Context) -> UIImagePickerController { let picker = UIImagePickerController() picker.delegate = context.coordinator picker.sourceType = sourceType return picker } func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) { } func makeCoordinator() -> Coordinator { return Coordinator(self) } } class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate { let parent: ImagePicker init(_ parent: ImagePicker) { self.parent = parent } func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) { if let image = info[.originalImage] as? UIImage { parent.selectedImage = image parent.isShowing = false } } func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { parent.isShowing = false } } enum ImageCropAspectPolicy { case square case free } private enum CropHandle: CaseIterable, Identifiable { case topLeading case topTrailing case bottomLeading case bottomTrailing var id: Self { self } var xDirection: CGFloat { switch self { case .topLeading, .bottomLeading: return -1 case .topTrailing, .bottomTrailing: return 1 } } var yDirection: CGFloat { switch self { case .topLeading, .topTrailing: return -1 case .bottomLeading, .bottomTrailing: return 1 } } } struct ImageCropEditorView: View { let image: UIImage let aspectPolicy: ImageCropAspectPolicy let onCancel: () -> Void let onComplete: (UIImage) -> Void private let normalizedImage: UIImage @State private var canvasSize: CGSize = .zero @State private var scale: CGFloat = 1 @State private var lastScale: CGFloat = 1 @State private var offset: CGSize = .zero @State private var lastOffset: CGSize = .zero @State private var freeCropSize: CGSize = .zero @State private var lastFreeCropSize: CGSize = .zero init( image: UIImage, aspectPolicy: ImageCropAspectPolicy, onCancel: @escaping () -> Void, onComplete: @escaping (UIImage) -> Void ) { self.image = image self.aspectPolicy = aspectPolicy self.onCancel = onCancel self.onComplete = onComplete self.normalizedImage = image.normalizedForCrop() } var body: some View { VStack(spacing: 0) { HStack { Button(action: onCancel) { Text("취소") .appFont(size: 16.7, weight: .bold) .foregroundColor(Color.grayee) } Spacer() Button(action: { if let croppedImage = cropImage() { onComplete(croppedImage) } else { onCancel() } }) { Text("적용") .appFont(size: 16.7, weight: .bold) .foregroundColor(Color.button) } } .padding(.horizontal, 16) .padding(.top, 16) .padding(.bottom, 12) .background(Color.black.opacity(0.96)) .zIndex(2) GeometryReader { proxy in let currentCanvasSize = proxy.size let currentCropSize = cropSize(in: currentCanvasSize) ZStack { Color.black Image(uiImage: image) .resizable() .scaledToFit() .scaleEffect(scale) .offset(offset) .frame(width: currentCanvasSize.width, height: currentCanvasSize.height) .gesture( DragGesture() .onChanged { value in offset = CGSize( width: lastOffset.width + value.translation.width, height: lastOffset.height + value.translation.height ) adjustTransforms(canvasSize: currentCanvasSize, cropSize: currentCropSize) } .onEnded { _ in adjustTransforms(canvasSize: currentCanvasSize, cropSize: currentCropSize) lastOffset = offset } ) .simultaneousGesture( MagnificationGesture() .onChanged { value in scale = max(lastScale * value, 1) adjustTransforms(canvasSize: currentCanvasSize, cropSize: currentCropSize) } .onEnded { _ in adjustTransforms(canvasSize: currentCanvasSize, cropSize: currentCropSize) lastOffset = offset lastScale = scale } ) cropOverlay(cropSize: currentCropSize, canvasSize: currentCanvasSize) .allowsHitTesting(false) if aspectPolicy == .free { ForEach(CropHandle.allCases) { handle in Circle() .fill(Color.button) .frame(width: 24, height: 24) .overlay( Circle() .stroke(Color.white.opacity(0.95), lineWidth: 2) ) .position(handlePosition(handle: handle, cropSize: currentCropSize, canvasSize: currentCanvasSize)) .gesture( DragGesture() .onChanged { value in resizeFreeCrop( with: value.translation, handle: handle, canvasSize: currentCanvasSize, currentCropSize: currentCropSize ) } .onEnded { _ in lastFreeCropSize = freeCropSize adjustTransforms(canvasSize: currentCanvasSize, cropSize: freeCropSize) lastOffset = offset } ) } } } .clipped() .onAppear { canvasSize = currentCanvasSize if aspectPolicy == .free, freeCropSize == .zero { freeCropSize = defaultFreeCropSize(in: currentCanvasSize) lastFreeCropSize = freeCropSize } adjustTransforms(canvasSize: currentCanvasSize, cropSize: currentCropSize) lastOffset = offset lastScale = scale } .onChange(of: proxy.size, perform: { newSize in canvasSize = newSize if aspectPolicy == .free, freeCropSize == .zero { freeCropSize = defaultFreeCropSize(in: newSize) lastFreeCropSize = freeCropSize } adjustTransforms(canvasSize: newSize, cropSize: cropSize(in: newSize)) lastOffset = offset lastScale = scale }) } if aspectPolicy == .free { Text("모서리 원을 드래그해서 크롭 영역 크기를 조정하세요") .appFont(size: 13.3, weight: .medium) .foregroundColor(Color.grayee) .padding(.top, 8) .padding(.bottom, 4) } Color.black .frame(height: 8) } .background(Color.black) } @ViewBuilder private func cropOverlay(cropSize: CGSize, canvasSize: CGSize) -> some View { let cropRect = CGRect( x: (canvasSize.width - cropSize.width) / 2, y: (canvasSize.height - cropSize.height) / 2, width: cropSize.width, height: cropSize.height ) ZStack { Color.black.opacity(0.6) Rectangle() .frame(width: cropSize.width, height: cropSize.height) .position(x: canvasSize.width / 2, y: canvasSize.height / 2) .blendMode(.destinationOut) } .compositingGroup() Rectangle() .stroke(Color.white.opacity(0.9), lineWidth: 1.5) .frame(width: cropRect.width, height: cropRect.height) .position(x: cropRect.midX, y: cropRect.midY) } private func cropSize(in canvas: CGSize) -> CGSize { switch aspectPolicy { case .square: let side = max(120, min(canvas.width, canvas.height) * 0.72) return CGSize(width: side, height: side) case .free: if freeCropSize == .zero { return defaultFreeCropSize(in: canvas) } let minCropSize: CGFloat = 120 let maxCropWidth = max(minCropSize, canvas.width - 24) let maxCropHeight = max(minCropSize, canvas.height - 24) return CGSize( width: freeCropSize.width.clamped(min: minCropSize, max: maxCropWidth), height: freeCropSize.height.clamped(min: minCropSize, max: maxCropHeight) ) } } private func defaultFreeCropSize(in canvas: CGSize) -> CGSize { let width = max(120, min(canvas.width * 0.82, canvas.width - 24)) let height = max(120, min(canvas.height * 0.58, canvas.height - 24)) return CGSize(width: width, height: height) } private func handlePosition(handle: CropHandle, cropSize: CGSize, canvasSize: CGSize) -> CGPoint { let centerX = canvasSize.width / 2 let centerY = canvasSize.height / 2 let halfWidth = cropSize.width / 2 let halfHeight = cropSize.height / 2 let x = centerX + (halfWidth * handle.xDirection) let y = centerY + (halfHeight * handle.yDirection) return CGPoint(x: x, y: y) } private func resizeFreeCrop( with translation: CGSize, handle: CropHandle, canvasSize: CGSize, currentCropSize: CGSize ) { if lastFreeCropSize == .zero { lastFreeCropSize = currentCropSize } let minCropSize: CGFloat = 120 let maxCropWidth = max(minCropSize, canvasSize.width - 24) let maxCropHeight = max(minCropSize, canvasSize.height - 24) let width = (lastFreeCropSize.width + (translation.width * handle.xDirection)) .clamped(min: minCropSize, max: maxCropWidth) let height = (lastFreeCropSize.height + (translation.height * handle.yDirection)) .clamped(min: minCropSize, max: maxCropHeight) freeCropSize = CGSize(width: width, height: height) adjustTransforms(canvasSize: canvasSize, cropSize: freeCropSize) } private func adjustTransforms(canvasSize: CGSize, cropSize: CGSize) { guard canvasSize.width > 0, canvasSize.height > 0 else { return } let fittedSize = fittedImageSize(imageSize: normalizedImage.size, canvasSize: canvasSize) guard fittedSize.width > 0, fittedSize.height > 0 else { return } let minScale = max(cropSize.width / fittedSize.width, cropSize.height / fittedSize.height) scale = scale.clamped(min: minScale, max: 6) let scaledWidth = fittedSize.width * scale let scaledHeight = fittedSize.height * scale let limitX = max(0, (scaledWidth - cropSize.width) / 2) let limitY = max(0, (scaledHeight - cropSize.height) / 2) offset = CGSize( width: offset.width.clamped(min: -limitX, max: limitX), height: offset.height.clamped(min: -limitY, max: limitY) ) } private func cropImage() -> UIImage? { let cropSize = cropSize(in: canvasSize) guard canvasSize.width > 0, canvasSize.height > 0 else { return nil } guard let cgImage = normalizedImage.cgImage else { return nil } let fittedSize = fittedImageSize(imageSize: normalizedImage.size, canvasSize: canvasSize) guard fittedSize.width > 0, fittedSize.height > 0 else { return nil } let scaledWidth = fittedSize.width * scale let scaledHeight = fittedSize.height * scale guard scaledWidth > 0, scaledHeight > 0 else { return nil } let cropXInScaled = ((scaledWidth - cropSize.width) / 2) - offset.width let cropYInScaled = ((scaledHeight - cropSize.height) / 2) - offset.height let xRatio = normalizedImage.size.width / scaledWidth let yRatio = normalizedImage.size.height / scaledHeight var cropRect = CGRect( x: cropXInScaled * xRatio, y: cropYInScaled * yRatio, width: cropSize.width * xRatio, height: cropSize.height * yRatio ).integral cropRect.origin.x = cropRect.origin.x.clamped(min: 0, max: max(0, normalizedImage.size.width - 1)) cropRect.origin.y = cropRect.origin.y.clamped(min: 0, max: max(0, normalizedImage.size.height - 1)) cropRect.size.width = cropRect.size.width.clamped(min: 1, max: normalizedImage.size.width - cropRect.origin.x) cropRect.size.height = cropRect.size.height.clamped(min: 1, max: normalizedImage.size.height - cropRect.origin.y) guard let croppedCgImage = cgImage.cropping(to: cropRect) else { return nil } let croppedImage = UIImage(cgImage: croppedCgImage, scale: normalizedImage.scale, orientation: .up) return croppedImage.resizedToMaxDimension(800) } private func fittedImageSize(imageSize: CGSize, canvasSize: CGSize) -> CGSize { guard imageSize.width > 0, imageSize.height > 0 else { return .zero } let ratio = min(canvasSize.width / imageSize.width, canvasSize.height / imageSize.height) return CGSize(width: imageSize.width * ratio, height: imageSize.height * ratio) } } extension UIImage { func normalizedForCrop() -> UIImage { if imageOrientation == .up { return self } let renderer = UIGraphicsImageRenderer(size: size) return renderer.image { _ in draw(in: CGRect(origin: .zero, size: size)) } } func resizedToMaxDimension(_ maxDimension: CGFloat) -> UIImage { guard size.width > 0, size.height > 0 else { return self } let largestSide = max(size.width, size.height) guard largestSide > maxDimension else { return self } let scaleRatio = maxDimension / largestSide let targetSize = CGSize(width: size.width * scaleRatio, height: size.height * scaleRatio) let format = UIGraphicsImageRendererFormat.default() format.scale = 1 let renderer = UIGraphicsImageRenderer(size: targetSize, format: format) return renderer.image { _ in draw(in: CGRect(origin: .zero, size: targetSize)) } } } private extension CGFloat { func clamped(min lower: CGFloat, max upper: CGFloat) -> CGFloat { Swift.max(lower, Swift.min(self, upper)) } }