feat(image): 이미지 선택 후 크롭 편집 흐름을 적용한다

This commit is contained in:
Yu Sung
2026-03-17 14:39:42 +09:00
parent 408c3b7619
commit 99fcf3a94c
8 changed files with 784 additions and 93 deletions

View File

@@ -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 {

View File

@@ -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