From 99fcf3a94c2890c7bd8d1a2037a29435b380f103 Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Tue, 17 Mar 2026 14:39:42 +0900 Subject: [PATCH] =?UTF-8?q?feat(image):=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=20=ED=9B=84=20=ED=81=AC=EB=A1=AD=20=ED=8E=B8?= =?UTF-8?q?=EC=A7=91=20=ED=9D=90=EB=A6=84=EC=9D=84=20=EC=A0=81=EC=9A=A9?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SodaLive/Resources/Localizable.xcstrings | 47 +- .../Content/Create/ContentCreateView.swift | 55 ++- .../Write/CreatorCommunityWriteView.swift | 140 +++--- .../CreatorCommunityWriteViewModel.swift | 13 +- .../Sources/ImagePicker/ImagePicker.swift | 408 ++++++++++++++++++ .../Live/Room/Create/LiveRoomCreateView.swift | 69 ++- .../MyPage/Profile/ProfileUpdateView.swift | 77 +++- docs/20260317_이미지등록크롭재구현.md | 68 +++ 8 files changed, 784 insertions(+), 93 deletions(-) create mode 100644 docs/20260317_이미지등록크롭재구현.md diff --git a/SodaLive/Resources/Localizable.xcstrings b/SodaLive/Resources/Localizable.xcstrings index c825ca1..f0bde19 100644 --- a/SodaLive/Resources/Localizable.xcstrings +++ b/SodaLive/Resources/Localizable.xcstrings @@ -4129,18 +4129,18 @@ } } }, - "모든 기기에서 로그아웃" : { + "모집완료" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Log out from all devices" + "value" : "Recruitment closed" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "全端末からログアウト" + "value" : "募集終了" } } } @@ -4161,21 +4161,24 @@ } } }, - "모집완료" : { + "모든 기기에서 로그아웃" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Recruitment closed" + "value" : "Log out from all devices" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "募集終了" + "value" : "全端末からログアウト" } } } + }, + "모서리 원을 드래그해서 크롭 영역 크기를 조정하세요" : { + }, "모집중" : { "localizations" : { @@ -8653,22 +8656,6 @@ } } }, - "캔" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cans" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "CAN" - } - } - } - }, "캐릭터 정보" : { "localizations" : { "en" : { @@ -8685,6 +8672,22 @@ } } }, + "캔" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cans" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "CAN" + } + } + } + }, "캔 충전" : { "localizations" : { "en" : { diff --git a/SodaLive/Sources/Content/Create/ContentCreateView.swift b/SodaLive/Sources/Content/Create/ContentCreateView.swift index 64b72a2..769f431 100644 --- a/SodaLive/Sources/Content/Create/ContentCreateView.swift +++ b/SodaLive/Sources/Content/Create/ContentCreateView.swift @@ -14,6 +14,10 @@ struct ContentCreateView: View { @StateObject private var viewModel = ContentCreateViewModel() @State private var isShowPhotoPicker = false + @State private var selectedPickedImage: UIImage? + @State private var cropSourceImage: UIImage? + @State private var isShowImageCropper = false + @State private var isImageLoading = false @State private var isShowSelectAudioView = false @State private var isShowSelectThemeView = false @State private var isShowSelectDateView = false @@ -626,11 +630,11 @@ struct ContentCreateView: View { if isShowSelectTimeView { QuarterTimePickerView(selectedTime: $viewModel.releaseTime, isShowing: $isShowSelectTimeView) } - + if isShowPhotoPicker { ImagePicker( isShowing: $isShowPhotoPicker, - selectedImage: $viewModel.coverImage, + selectedImage: $selectedPickedImage, sourceType: .photoLibrary ) } @@ -659,10 +663,57 @@ struct ContentCreateView: View { } } .edgesIgnoringSafeArea(.bottom) + + if isImageLoading { + ZStack { + Color.black.opacity(0.45) + .edgesIgnoringSafeArea(.all) + + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + } + } } .onTapGesture { hideKeyboard() } .edgesIgnoringSafeArea(.bottom) .sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2) + .onChange(of: selectedPickedImage, perform: { newImage in + guard let newImage else { + return + } + + isImageLoading = true + + DispatchQueue.global(qos: .userInitiated).async { + let normalizedImage = newImage.normalizedForCrop() + DispatchQueue.main.async { + isImageLoading = false + selectedPickedImage = nil + cropSourceImage = normalizedImage + isShowImageCropper = true + } + } + }) + .onDisappear { + isImageLoading = false + } + .sheet(isPresented: $isShowImageCropper, onDismiss: { + cropSourceImage = nil + }) { + if let cropSourceImage { + ImageCropEditorView( + image: cropSourceImage, + aspectPolicy: .square, + onCancel: { + isShowImageCropper = false + }, + onComplete: { croppedImage in + viewModel.coverImage = croppedImage + isShowImageCropper = false + } + ) + } + } } } } diff --git a/SodaLive/Sources/Explorer/Profile/CreatorCommunity/Write/CreatorCommunityWriteView.swift b/SodaLive/Sources/Explorer/Profile/CreatorCommunity/Write/CreatorCommunityWriteView.swift index 745b078..fe650d4 100644 --- a/SodaLive/Sources/Explorer/Profile/CreatorCommunity/Write/CreatorCommunityWriteView.swift +++ b/SodaLive/Sources/Explorer/Profile/CreatorCommunity/Write/CreatorCommunityWriteView.swift @@ -6,15 +6,17 @@ // import SwiftUI -import PhotosUI -import SDWebImageSwiftUI struct CreatorCommunityWriteView: View { @StateObject var keyboardHandler = KeyboardHandler() @StateObject private var viewModel = CreatorCommunityWriteViewModel() - @State private var selectedItem: PhotosPickerItem? = nil + @State private var selectedPickedImage: UIImage? + @State private var cropSourceImage: UIImage? + @State private var isShowImageCropper = false + @State private var isShowPhotoPicker = false + @State private var isImageLoading = false @State private var isShowRecordingVoiceView = false @State private var fileName: String = "녹음" @@ -38,53 +40,35 @@ struct CreatorCommunityWriteView: View { .foregroundColor(Color.grayee) .frame(maxWidth: .infinity, alignment: .leading) - PhotosPicker( - selection: $selectedItem, - matching: .any(of: [.images]), - photoLibrary: .shared()) { - ZStack(alignment: .bottomTrailing) { - if let selectedImage = viewModel.postImageData { - AnimatedImage(data: selectedImage) - .resizable() - .scaledToFill() - .frame(width: imagePreviewSize, height: imagePreviewSize) - .cornerRadius(8) - .clipped() - } else { - Image("ic_logo2") - .resizable() - .scaledToFit() - .padding(13.3) - .frame(width: imagePreviewSize, height: imagePreviewSize) - .background(Color.bg) - .cornerRadius(8) - .clipped() - } - - Image("ic_camera") - .padding(10) - .background(Color.button) - .cornerRadius(30) - .offset(x: 15, y: 0) - } - .frame(width: imagePreviewSize, height: imagePreviewSize, alignment: .bottomTrailing) - } - .onChange(of: selectedItem) { newItem in - Task { - if let item = newItem { - do { - // ✅ 이미지 원본 Data 가져오기 (GIF 포함) - if let data = try await item.loadTransferable(type: Data.self) { - viewModel.postImageData = data - } - } catch { - viewModel.errorMessage = "이미지를 로드하지 못했습니다." - viewModel.isShowPopup = true - DEBUG_LOG("이미지 로드 실패: \(error)") - } - } - } + ZStack(alignment: .bottomTrailing) { + if let selectedImage = viewModel.postImage { + Image(uiImage: selectedImage) + .resizable() + .scaledToFill() + .frame(width: imagePreviewSize, height: imagePreviewSize) + .cornerRadius(8) + .clipped() + } else { + Image("ic_logo2") + .resizable() + .scaledToFit() + .padding(13.3) + .frame(width: imagePreviewSize, height: imagePreviewSize) + .background(Color.bg) + .cornerRadius(8) + .clipped() } + + Image("ic_camera") + .padding(10) + .background(Color.button) + .cornerRadius(30) + .offset(x: 15, y: 0) + } + .frame(width: imagePreviewSize, height: imagePreviewSize, alignment: .bottomTrailing) + .onTapGesture { + isShowPhotoPicker = true + } HStack(alignment: .top, spacing: 0) { Text("※ ") @@ -341,10 +325,64 @@ struct CreatorCommunityWriteView: View { soundData: $viewModel.soundData ) } + + if isShowPhotoPicker { + ImagePicker( + isShowing: $isShowPhotoPicker, + selectedImage: $selectedPickedImage, + sourceType: .photoLibrary + ) + } + + if isImageLoading { + ZStack { + Color.black.opacity(0.45) + .edgesIgnoringSafeArea(.all) + + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + } + } } .onTapGesture { hideKeyboard() } .edgesIgnoringSafeArea(.bottom) .sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2) + .onDisappear { + isImageLoading = false + } + .onChange(of: selectedPickedImage, perform: { newImage in + guard let newImage else { + return + } + + isImageLoading = true + DispatchQueue.global(qos: .userInitiated).async { + let normalizedImage = newImage.normalizedForCrop() + DispatchQueue.main.async { + isImageLoading = false + selectedPickedImage = nil + cropSourceImage = normalizedImage + isShowImageCropper = true + } + } + }) + .sheet(isPresented: $isShowImageCropper, onDismiss: { + cropSourceImage = nil + }) { + if let cropSourceImage { + ImageCropEditorView( + image: cropSourceImage, + aspectPolicy: .free, + onCancel: { + isShowImageCropper = false + }, + onComplete: { croppedImage in + viewModel.setPostImage(croppedImage) + isShowImageCropper = false + } + ) + } + } } } } @@ -352,7 +390,9 @@ struct CreatorCommunityWriteView: View { private func deleteAudioFile() { do { try FileManager.default.removeItem(at: getAudioFileURL()) - } catch {} + } catch { + DEBUG_LOG("오디오 파일 삭제 실패: \(error)") + } } private func getAudioFileURL() -> URL { diff --git a/SodaLive/Sources/Explorer/Profile/CreatorCommunity/Write/CreatorCommunityWriteViewModel.swift b/SodaLive/Sources/Explorer/Profile/CreatorCommunity/Write/CreatorCommunityWriteViewModel.swift index bfe3588..2ef6fea 100644 --- a/SodaLive/Sources/Explorer/Profile/CreatorCommunity/Write/CreatorCommunityWriteViewModel.swift +++ b/SodaLive/Sources/Explorer/Profile/CreatorCommunity/Write/CreatorCommunityWriteViewModel.swift @@ -29,6 +29,7 @@ final class CreatorCommunityWriteViewModel: ObservableObject { } @Published var isAvailableComment = true @Published var postImageData: Data? = nil + @Published var postImage: UIImage? = nil @Published var priceString = "0" { didSet { @@ -47,6 +48,11 @@ final class CreatorCommunityWriteViewModel: ObservableObject { @Published var soundData: Data? = nil var placeholder = "내용을 입력하세요" + + func setPostImage(_ image: UIImage) { + postImage = image + postImageData = image.jpegData(compressionQuality: 0.8) + } func createCommunityPost(onSuccess: @escaping () -> Void) { if !isLoading && validateData() { @@ -65,8 +71,8 @@ final class CreatorCommunityWriteViewModel: ObservableObject { MultipartFormData( provider: .data(postImageData), name: "postImage", - fileName: "\(UUID().uuidString)_\(Date().timeIntervalSince1970 * 1000)", - mimeType: "image/*" + fileName: "\(UUID().uuidString)_\(Date().timeIntervalSince1970 * 1000).jpg", + mimeType: "image/jpeg" ) ) } @@ -92,6 +98,9 @@ final class CreatorCommunityWriteViewModel: ObservableObject { DEBUG_LOG("finish") case .failure(let error): ERROR_LOG(error.localizedDescription) + self.isLoading = false + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.isShowPopup = true } } receiveValue: { [unowned self] response in self.isLoading = false diff --git a/SodaLive/Sources/ImagePicker/ImagePicker.swift b/SodaLive/Sources/ImagePicker/ImagePicker.swift index 728af30..ef2743d 100644 --- a/SodaLive/Sources/ImagePicker/ImagePicker.swift +++ b/SodaLive/Sources/ImagePicker/ImagePicker.swift @@ -6,6 +6,7 @@ // import SwiftUI +import UIKit struct ImagePicker: UIViewControllerRepresentable { @@ -17,6 +18,7 @@ struct ImagePicker: UIViewControllerRepresentable { func makeUIViewController(context: Context) -> UIImagePickerController { let picker = UIImagePickerController() picker.delegate = context.coordinator + picker.sourceType = sourceType return picker } @@ -46,3 +48,409 @@ class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationContro 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)) + } +} diff --git a/SodaLive/Sources/Live/Room/Create/LiveRoomCreateView.swift b/SodaLive/Sources/Live/Room/Create/LiveRoomCreateView.swift index 4a125de..db8a7ed 100644 --- a/SodaLive/Sources/Live/Room/Create/LiveRoomCreateView.swift +++ b/SodaLive/Sources/Live/Room/Create/LiveRoomCreateView.swift @@ -14,6 +14,10 @@ struct LiveRoomCreateView: View { @StateObject var viewModel = LiveRoomCreateViewModel() @State private var isShowPhotoPicker = false + @State private var selectedPickedImage: UIImage? + @State private var cropSourceImage: UIImage? + @State private var isShowImageCropper = false + @State private var isImageLoading = false @State private var isShowSelectTagView = false @State private var isShowSelectDateView = false @State private var isShowSelectTimeView = false @@ -233,14 +237,6 @@ struct LiveRoomCreateView: View { SelectTimeView() } - if isShowPhotoPicker { - ImagePicker( - isShowing: $isShowPhotoPicker, - selectedImage: $viewModel.coverImage, - sourceType: .photoLibrary - ) - } - GeometryReader { proxy in VStack { Spacer() @@ -254,6 +250,24 @@ struct LiveRoomCreateView: View { } } .edgesIgnoringSafeArea(.bottom) + + if isShowPhotoPicker { + ImagePicker( + isShowing: $isShowPhotoPicker, + selectedImage: $selectedPickedImage, + sourceType: .photoLibrary + ) + } + + if isImageLoading { + ZStack { + Color.black.opacity(0.45) + .edgesIgnoringSafeArea(.all) + + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + } + } } .onTapGesture { hideKeyboard() @@ -266,6 +280,45 @@ struct LiveRoomCreateView: View { viewModel.timeSettingMode = timeSettingMode viewModel.getAllMenuPreset() } + .onChange(of: selectedPickedImage, perform: { newImage in + guard let newImage else { + return + } + + isImageLoading = true + + DispatchQueue.global(qos: .userInitiated).async { + let normalizedImage = newImage.normalizedForCrop() + DispatchQueue.main.async { + isImageLoading = false + selectedPickedImage = nil + cropSourceImage = normalizedImage + isShowImageCropper = true + } + } + }) + .onDisappear { + isImageLoading = false + } + .sheet(isPresented: $isShowImageCropper, onDismiss: { + cropSourceImage = nil + }) { + if let cropSourceImage { + ImageCropEditorView( + image: cropSourceImage, + aspectPolicy: .free, + onCancel: { + isShowImageCropper = false + }, + onComplete: { croppedImage in + viewModel.coverImage = croppedImage + viewModel.coverImageUrl = nil + viewModel.coverImagePath = nil + isShowImageCropper = false + } + ) + } + } } @ViewBuilder diff --git a/SodaLive/Sources/MyPage/Profile/ProfileUpdateView.swift b/SodaLive/Sources/MyPage/Profile/ProfileUpdateView.swift index 152eac2..025e39c 100644 --- a/SodaLive/Sources/MyPage/Profile/ProfileUpdateView.swift +++ b/SodaLive/Sources/MyPage/Profile/ProfileUpdateView.swift @@ -13,6 +13,11 @@ struct ProfileUpdateView: View { @StateObject var keyboardHandler = KeyboardHandler() @State private var isShowPhotoPicker = false + @State private var selectedPickedImage: UIImage? + @State private var cropSourceImage: UIImage? + @State private var isShowImageCropper = false + @State private var selectedProfileImage: UIImage? + @State private var isImageLoading = false @State private var isShowSelectTagView = false let refresh: () -> Void @@ -297,7 +302,13 @@ struct ProfileUpdateView: View { VStack(spacing: 0) { if let profileResponse = viewModel.profileResponse { ZStack { - if profileResponse.profileUrl.trimmingCharacters(in: .whitespaces).count > 0 { + if let selectedProfileImage { + Image(uiImage: selectedProfileImage) + .resizable() + .scaledToFill() + .frame(width: 80, height: 80, alignment: .top) + .clipShape(Circle()) + } else if profileResponse.profileUrl.trimmingCharacters(in: .whitespaces).count > 0 { KFImage(URL(string: profileResponse.profileUrl)) .cancelOnDisappear(true) .downsampling( @@ -407,14 +418,6 @@ struct ProfileUpdateView: View { } } - if isShowPhotoPicker { - ImagePicker( - isShowing: $isShowPhotoPicker, - selectedImage: $viewModel.profileImage, - sourceType: .photoLibrary - ) - } - if isShowSelectTagView { GeometryReader { proxy in VStack { @@ -437,6 +440,16 @@ struct ProfileUpdateView: View { } .edgesIgnoringSafeArea(.bottom) } + + if isImageLoading { + ZStack { + Color.black.opacity(0.45) + .edgesIgnoringSafeArea(.all) + + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + } + } } .offset(y: keyboardHandler.keyboardHeight > 0 ? -keyboardHandler.keyboardHeight + 15.3 : 0) .edgesIgnoringSafeArea(.bottom) @@ -449,6 +462,52 @@ struct ProfileUpdateView: View { viewModel.getMyProfile() } .sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2) + .onChange(of: selectedPickedImage, perform: { newImage in + guard let newImage else { + return + } + + isImageLoading = true + + DispatchQueue.global(qos: .userInitiated).async { + let normalizedImage = newImage.normalizedForCrop() + DispatchQueue.main.async { + isImageLoading = false + selectedPickedImage = nil + cropSourceImage = normalizedImage + isShowImageCropper = true + } + } + }) + .onDisappear { + isImageLoading = false + } + .sheet(isPresented: $isShowImageCropper, onDismiss: { + cropSourceImage = nil + }) { + if let cropSourceImage { + ImageCropEditorView( + image: cropSourceImage, + aspectPolicy: .square, + onCancel: { + isShowImageCropper = false + }, + onComplete: { croppedImage in + selectedProfileImage = croppedImage + viewModel.profileImage = croppedImage + isShowImageCropper = false + } + ) + } + } + + if isShowPhotoPicker { + ImagePicker( + isShowing: $isShowPhotoPicker, + selectedImage: $selectedPickedImage, + sourceType: .photoLibrary + ) + } } } } diff --git a/docs/20260317_이미지등록크롭재구현.md b/docs/20260317_이미지등록크롭재구현.md new file mode 100644 index 0000000..25c6ca9 --- /dev/null +++ b/docs/20260317_이미지등록크롭재구현.md @@ -0,0 +1,68 @@ +# 20260317 이미지 등록/크롭 재구현 + +## 구현 체크리스트 +- [x] 공통 SwiftUI 이미지 선택/크롭 컴포넌트 구현 + - QA: 1:1 고정 모드와 자유 비율 모드에서 크롭 완료 시 `UIImage` 반환 +- [x] 콘텐츠 업로드(`ContentCreateView`) 이미지 등록 플로우를 신규 컴포넌트로 전환 + - QA: 선택 후 1:1 크롭 결과가 썸네일에 반영되고 업로드 시 `coverImage`로 전송 +- [x] 라이브 만들기(`LiveRoomCreateView`) 이미지 등록 플로우를 신규 컴포넌트로 전환 + - QA: 선택 후 자유 비율 크롭 결과가 썸네일에 반영되고 업로드 시 `coverImage`로 전송 +- [x] 커뮤니티 게시글 등록(`CreatorCommunityWriteView`) 이미지 등록 플로우를 신규 컴포넌트로 전환 + - QA: 선택 후 자유 비율 크롭 결과가 미리보기에 반영되고 업로드 시 `postImageData`로 전송 +- [x] 프로필 이미지 등록(`ProfileUpdateView`) 이미지 등록 플로우를 신규 컴포넌트로 전환 + - QA: 선택 후 1:1 크롭 결과가 반영되고 업로드 시 `profileImage` 업데이트 트리거 +- [x] 이미지 업로드 지점 추가 전수 검색 및 결과 정리 + - QA: `ImagePicker`, `PhotosPicker`, `MultipartFormData(name: "image"/"coverImage"/"postImage")` 기반 검색 결과 보고 +- [x] 정적 진단/빌드 검증 및 결과 기록 + - QA: 수정 파일 `lsp_diagnostics` 오류 0건, 빌드 명령 성공 + +## 검증 기록 +- 2026-03-17 + - 무엇/왜/어떻게: 4개 대상 화면의 이미지 선택을 `PhotosPicker` 기반으로 통일하고, 공통 `ImageCropEditorView`를 `ImagePicker.swift`에 추가해 1:1/자유 비율 크롭을 분기 적용했다. 커뮤니티 등록은 크롭 결과 `UIImage`를 `jpegData`로 변환해 기존 `postImageData` 업로드 체인과 호환시켰다. + - 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build` + - 결과: 초기 2회는 iOS 16.6 타깃에서 `onChange` 시그니처 이슈로 실패했으며 수정 후 최종 `** BUILD SUCCEEDED **` 확인. + - 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test` + - 결과: `Scheme SodaLive is not currently configured for the test action.`로 테스트 액션 미구성 확인. + - 실행 명령: `grep`/`ast-grep` 전수 검색(`ImagePicker(`, `PhotosPicker(`, `jpegData(compressionQuality: 0.8)`, `name: "image|coverImage|postImage"`, `MultipartFormData`) + - 결과: 추가 이미지 업로드 지점 3곳 확인 + - `SodaLive/Sources/Live/Room/LiveRoomViewModel.swift` (`coverImage` 업로드) + - `SodaLive/Sources/Content/Modify/ContentModifyViewModel.swift` (`coverImage` 업로드) + - `SodaLive/Sources/Explorer/Profile/CreatorCommunity/Modify/CreatorCommunityModifyViewModel.swift` (`postImage` 업로드) +- 2026-03-17 (보강) + - 무엇/왜/어떻게: Oracle 리뷰에서 지적된 안정성 항목을 반영해 커뮤니티 업로드 실패 시 로딩 고착을 해소하고, `postImage`의 MIME/확장자를 `image/jpeg`/`.jpg`로 명시했다. 또한 크롭 편집기에서 오프셋 스냅샷 동기화와 경계 좌표 보정을 추가하고, 4개 화면의 비동기 이미지 로딩 Task 취소 처리를 넣어 연속 선택 경쟁 상태를 줄였다. + - 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build` + - 결과: `** BUILD SUCCEEDED **` + - 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test` + - 결과: `Scheme SodaLive is not currently configured for the test action.` +- 2026-03-17 (크롭 표시 지연/미표시 수정) + - 무엇/왜/어떻게: `PhotosPicker` 선택값이 `nil`로 바뀔 때도 기존 로딩 Task를 즉시 취소하던 흐름 때문에 크롭 표시 트리거가 유실될 수 있어, 4개 화면 모두 `newItem != nil`일 때만 로딩을 시작하도록 수정했다. 동시에 이미지 로드 중에는 반투명 오버레이 + `ProgressView`를 표시해 크롭 UI 전환 대기 시간을 자연스럽게 보이도록 적용했다. + - 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build` + - 결과: `** BUILD SUCCEEDED **` + - 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test` + - 결과: `Scheme SodaLive is not currently configured for the test action.` +- 2026-03-17 (iOS 16 Task/자유 크롭 멈춤 보강) + - 무엇/왜/어떻게: iOS 16에서 `Task` 기반 이미지 로딩 경로가 불안정하다는 이슈에 따라 4개 화면의 이미지 선택 로직을 `ImagePicker(UIImagePickerController)` + 콜백 기반으로 전환하고 `Task`/`PhotosPicker` 의존을 제거했다. 선택 직후에는 백그라운드에서 `normalizedForCrop()`를 수행하며 로딩 오버레이를 보여주고, 완료 시 크롭 시트를 띄우도록 변경했다. 자유 크롭 멈춤 이슈는 크롭 오버레이 hit-test를 차단하고(이미지 제스처 통과), 부모/핸들 제스처 충돌을 줄이도록 제스처 부착 위치를 조정했으며 정규화 이미지를 캐시해 제스처 중 과도한 연산을 제거했다. + - 실행 명령: `grep` 점검(`Task|PhotosPicker|selectedPhotoItem|loadTransferable`) + - 결과: 대상 4개 화면에서 `Task/PhotosPicker` 경로 미검출 확인. + - 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build` + - 결과: `** BUILD SUCCEEDED **` + - 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test` + - 결과: `Scheme SodaLive is not currently configured for the test action.` +- 2026-03-17 (크롭 다이얼로그 사용성 보강) + - 무엇/왜/어떻게: 확대 시 상단 버튼이 가려지던 문제를 막기 위해 크롭 캔버스를 `clipped()` 처리하고 상단 버튼 영역을 고정(`zIndex` + 배경)했다. 자유 크롭은 단일 우하단 원 대신 4개 모서리 핸들로 변경하고, 조작 안내 문구를 추가해 조절 방법을 즉시 인지할 수 있도록 개선했다. + - 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build` + - 결과: `** BUILD SUCCEEDED **` + - 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test` + - 결과: `Scheme SodaLive is not currently configured for the test action.` +- 2026-03-17 (크롭 결과 크기 제한/핸들 크기 조정) + - 무엇/왜/어떻게: 크롭 결과 이미지의 가로/세로 중 큰 값을 기준으로 최대 800px을 넘지 않도록 `resizedToMaxDimension(800)` 축소 로직을 추가했다(비율 유지). 자유 크롭 핸들 원은 기존 대비 절반 크기(30 -> 15)로 조정했다. + - 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build` + - 결과: `** BUILD SUCCEEDED **` + - 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test` + - 결과: `Scheme SodaLive is not currently configured for the test action.` +- 2026-03-17 (핸들 크기 재조정) + - 무엇/왜/어떻게: 요청에 맞춰 자유 크롭 핸들 원 크기를 `24`로 조정했다. + - 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build` + - 결과: `** BUILD SUCCEEDED **` + - 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test` + - 결과: `Scheme SodaLive is not currently configured for the test action.`