feat(image): 이미지 선택 후 크롭 편집 흐름을 적용한다
This commit is contained in:
@@ -4129,18 +4129,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"모든 기기에서 로그아웃" : {
|
"모집완료" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "Log out from all devices"
|
"value" : "Recruitment closed"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ja" : {
|
"ja" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "全端末からログアウト"
|
"value" : "募集終了"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4161,21 +4161,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"모집완료" : {
|
"모든 기기에서 로그아웃" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "Recruitment closed"
|
"value" : "Log out from all devices"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ja" : {
|
"ja" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "募集終了"
|
"value" : "全端末からログアウト"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"모서리 원을 드래그해서 크롭 영역 크기를 조정하세요" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"모집중" : {
|
"모집중" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -8653,22 +8656,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"캔" : {
|
|
||||||
"localizations" : {
|
|
||||||
"en" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "Cans"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ja" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "CAN"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"캐릭터 정보" : {
|
"캐릭터 정보" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
@@ -8685,6 +8672,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"캔" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Cans"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ja" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "CAN"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"캔 충전" : {
|
"캔 충전" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ struct ContentCreateView: View {
|
|||||||
@StateObject private var viewModel = ContentCreateViewModel()
|
@StateObject private var viewModel = ContentCreateViewModel()
|
||||||
|
|
||||||
@State private var isShowPhotoPicker = false
|
@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 isShowSelectAudioView = false
|
||||||
@State private var isShowSelectThemeView = false
|
@State private var isShowSelectThemeView = false
|
||||||
@State private var isShowSelectDateView = false
|
@State private var isShowSelectDateView = false
|
||||||
@@ -630,7 +634,7 @@ struct ContentCreateView: View {
|
|||||||
if isShowPhotoPicker {
|
if isShowPhotoPicker {
|
||||||
ImagePicker(
|
ImagePicker(
|
||||||
isShowing: $isShowPhotoPicker,
|
isShowing: $isShowPhotoPicker,
|
||||||
selectedImage: $viewModel.coverImage,
|
selectedImage: $selectedPickedImage,
|
||||||
sourceType: .photoLibrary
|
sourceType: .photoLibrary
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -659,10 +663,57 @@ struct ContentCreateView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.edgesIgnoringSafeArea(.bottom)
|
.edgesIgnoringSafeArea(.bottom)
|
||||||
|
|
||||||
|
if isImageLoading {
|
||||||
|
ZStack {
|
||||||
|
Color.black.opacity(0.45)
|
||||||
|
.edgesIgnoringSafeArea(.all)
|
||||||
|
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.onTapGesture { hideKeyboard() }
|
.onTapGesture { hideKeyboard() }
|
||||||
.edgesIgnoringSafeArea(.bottom)
|
.edgesIgnoringSafeArea(.bottom)
|
||||||
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
|
.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
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,15 +6,17 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import PhotosUI
|
|
||||||
import SDWebImageSwiftUI
|
|
||||||
|
|
||||||
struct CreatorCommunityWriteView: View {
|
struct CreatorCommunityWriteView: View {
|
||||||
|
|
||||||
@StateObject var keyboardHandler = KeyboardHandler()
|
@StateObject var keyboardHandler = KeyboardHandler()
|
||||||
@StateObject private var viewModel = CreatorCommunityWriteViewModel()
|
@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 isShowRecordingVoiceView = false
|
||||||
@State private var fileName: String = "녹음"
|
@State private var fileName: String = "녹음"
|
||||||
@@ -38,53 +40,35 @@ struct CreatorCommunityWriteView: View {
|
|||||||
.foregroundColor(Color.grayee)
|
.foregroundColor(Color.grayee)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
PhotosPicker(
|
ZStack(alignment: .bottomTrailing) {
|
||||||
selection: $selectedItem,
|
if let selectedImage = viewModel.postImage {
|
||||||
matching: .any(of: [.images]),
|
Image(uiImage: selectedImage)
|
||||||
photoLibrary: .shared()) {
|
.resizable()
|
||||||
ZStack(alignment: .bottomTrailing) {
|
.scaledToFill()
|
||||||
if let selectedImage = viewModel.postImageData {
|
.frame(width: imagePreviewSize, height: imagePreviewSize)
|
||||||
AnimatedImage(data: selectedImage)
|
.cornerRadius(8)
|
||||||
.resizable()
|
.clipped()
|
||||||
.scaledToFill()
|
} else {
|
||||||
.frame(width: imagePreviewSize, height: imagePreviewSize)
|
Image("ic_logo2")
|
||||||
.cornerRadius(8)
|
.resizable()
|
||||||
.clipped()
|
.scaledToFit()
|
||||||
} else {
|
.padding(13.3)
|
||||||
Image("ic_logo2")
|
.frame(width: imagePreviewSize, height: imagePreviewSize)
|
||||||
.resizable()
|
.background(Color.bg)
|
||||||
.scaledToFit()
|
.cornerRadius(8)
|
||||||
.padding(13.3)
|
.clipped()
|
||||||
.frame(width: imagePreviewSize, height: imagePreviewSize)
|
}
|
||||||
.background(Color.bg)
|
|
||||||
.cornerRadius(8)
|
|
||||||
.clipped()
|
|
||||||
}
|
|
||||||
|
|
||||||
Image("ic_camera")
|
Image("ic_camera")
|
||||||
.padding(10)
|
.padding(10)
|
||||||
.background(Color.button)
|
.background(Color.button)
|
||||||
.cornerRadius(30)
|
.cornerRadius(30)
|
||||||
.offset(x: 15, y: 0)
|
.offset(x: 15, y: 0)
|
||||||
}
|
}
|
||||||
.frame(width: imagePreviewSize, height: imagePreviewSize, alignment: .bottomTrailing)
|
.frame(width: imagePreviewSize, height: imagePreviewSize, alignment: .bottomTrailing)
|
||||||
}
|
.onTapGesture {
|
||||||
.onChange(of: selectedItem) { newItem in
|
isShowPhotoPicker = true
|
||||||
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)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
HStack(alignment: .top, spacing: 0) {
|
HStack(alignment: .top, spacing: 0) {
|
||||||
Text("※ ")
|
Text("※ ")
|
||||||
@@ -341,10 +325,64 @@ struct CreatorCommunityWriteView: View {
|
|||||||
soundData: $viewModel.soundData
|
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() }
|
.onTapGesture { hideKeyboard() }
|
||||||
.edgesIgnoringSafeArea(.bottom)
|
.edgesIgnoringSafeArea(.bottom)
|
||||||
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
|
.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() {
|
private func deleteAudioFile() {
|
||||||
do {
|
do {
|
||||||
try FileManager.default.removeItem(at: getAudioFileURL())
|
try FileManager.default.removeItem(at: getAudioFileURL())
|
||||||
} catch {}
|
} catch {
|
||||||
|
DEBUG_LOG("오디오 파일 삭제 실패: \(error)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func getAudioFileURL() -> URL {
|
private func getAudioFileURL() -> URL {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ final class CreatorCommunityWriteViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
@Published var isAvailableComment = true
|
@Published var isAvailableComment = true
|
||||||
@Published var postImageData: Data? = nil
|
@Published var postImageData: Data? = nil
|
||||||
|
@Published var postImage: UIImage? = nil
|
||||||
|
|
||||||
@Published var priceString = "0" {
|
@Published var priceString = "0" {
|
||||||
didSet {
|
didSet {
|
||||||
@@ -48,6 +49,11 @@ final class CreatorCommunityWriteViewModel: ObservableObject {
|
|||||||
|
|
||||||
var placeholder = "내용을 입력하세요"
|
var placeholder = "내용을 입력하세요"
|
||||||
|
|
||||||
|
func setPostImage(_ image: UIImage) {
|
||||||
|
postImage = image
|
||||||
|
postImageData = image.jpegData(compressionQuality: 0.8)
|
||||||
|
}
|
||||||
|
|
||||||
func createCommunityPost(onSuccess: @escaping () -> Void) {
|
func createCommunityPost(onSuccess: @escaping () -> Void) {
|
||||||
if !isLoading && validateData() {
|
if !isLoading && validateData() {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
@@ -65,8 +71,8 @@ final class CreatorCommunityWriteViewModel: ObservableObject {
|
|||||||
MultipartFormData(
|
MultipartFormData(
|
||||||
provider: .data(postImageData),
|
provider: .data(postImageData),
|
||||||
name: "postImage",
|
name: "postImage",
|
||||||
fileName: "\(UUID().uuidString)_\(Date().timeIntervalSince1970 * 1000)",
|
fileName: "\(UUID().uuidString)_\(Date().timeIntervalSince1970 * 1000).jpg",
|
||||||
mimeType: "image/*"
|
mimeType: "image/jpeg"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -92,6 +98,9 @@ final class CreatorCommunityWriteViewModel: ObservableObject {
|
|||||||
DEBUG_LOG("finish")
|
DEBUG_LOG("finish")
|
||||||
case .failure(let error):
|
case .failure(let error):
|
||||||
ERROR_LOG(error.localizedDescription)
|
ERROR_LOG(error.localizedDescription)
|
||||||
|
self.isLoading = false
|
||||||
|
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다."
|
||||||
|
self.isShowPopup = true
|
||||||
}
|
}
|
||||||
} receiveValue: { [unowned self] response in
|
} receiveValue: { [unowned self] response in
|
||||||
self.isLoading = false
|
self.isLoading = false
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
struct ImagePicker: UIViewControllerRepresentable {
|
struct ImagePicker: UIViewControllerRepresentable {
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ struct ImagePicker: UIViewControllerRepresentable {
|
|||||||
func makeUIViewController(context: Context) -> UIImagePickerController {
|
func makeUIViewController(context: Context) -> UIImagePickerController {
|
||||||
let picker = UIImagePickerController()
|
let picker = UIImagePickerController()
|
||||||
picker.delegate = context.coordinator
|
picker.delegate = context.coordinator
|
||||||
|
picker.sourceType = sourceType
|
||||||
return picker
|
return picker
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,3 +48,409 @@ class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationContro
|
|||||||
parent.isShowing = false
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ struct LiveRoomCreateView: View {
|
|||||||
@StateObject var viewModel = LiveRoomCreateViewModel()
|
@StateObject var viewModel = LiveRoomCreateViewModel()
|
||||||
|
|
||||||
@State private var isShowPhotoPicker = false
|
@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 isShowSelectTagView = false
|
||||||
@State private var isShowSelectDateView = false
|
@State private var isShowSelectDateView = false
|
||||||
@State private var isShowSelectTimeView = false
|
@State private var isShowSelectTimeView = false
|
||||||
@@ -233,14 +237,6 @@ struct LiveRoomCreateView: View {
|
|||||||
SelectTimeView()
|
SelectTimeView()
|
||||||
}
|
}
|
||||||
|
|
||||||
if isShowPhotoPicker {
|
|
||||||
ImagePicker(
|
|
||||||
isShowing: $isShowPhotoPicker,
|
|
||||||
selectedImage: $viewModel.coverImage,
|
|
||||||
sourceType: .photoLibrary
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
GeometryReader { proxy in
|
GeometryReader { proxy in
|
||||||
VStack {
|
VStack {
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -254,6 +250,24 @@ struct LiveRoomCreateView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.edgesIgnoringSafeArea(.bottom)
|
.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 {
|
.onTapGesture {
|
||||||
hideKeyboard()
|
hideKeyboard()
|
||||||
@@ -266,6 +280,45 @@ struct LiveRoomCreateView: View {
|
|||||||
viewModel.timeSettingMode = timeSettingMode
|
viewModel.timeSettingMode = timeSettingMode
|
||||||
viewModel.getAllMenuPreset()
|
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
|
@ViewBuilder
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ struct ProfileUpdateView: View {
|
|||||||
@StateObject var keyboardHandler = KeyboardHandler()
|
@StateObject var keyboardHandler = KeyboardHandler()
|
||||||
|
|
||||||
@State private var isShowPhotoPicker = false
|
@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
|
@State private var isShowSelectTagView = false
|
||||||
|
|
||||||
let refresh: () -> Void
|
let refresh: () -> Void
|
||||||
@@ -297,7 +302,13 @@ struct ProfileUpdateView: View {
|
|||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
if let profileResponse = viewModel.profileResponse {
|
if let profileResponse = viewModel.profileResponse {
|
||||||
ZStack {
|
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))
|
KFImage(URL(string: profileResponse.profileUrl))
|
||||||
.cancelOnDisappear(true)
|
.cancelOnDisappear(true)
|
||||||
.downsampling(
|
.downsampling(
|
||||||
@@ -407,14 +418,6 @@ struct ProfileUpdateView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if isShowPhotoPicker {
|
|
||||||
ImagePicker(
|
|
||||||
isShowing: $isShowPhotoPicker,
|
|
||||||
selectedImage: $viewModel.profileImage,
|
|
||||||
sourceType: .photoLibrary
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if isShowSelectTagView {
|
if isShowSelectTagView {
|
||||||
GeometryReader { proxy in
|
GeometryReader { proxy in
|
||||||
VStack {
|
VStack {
|
||||||
@@ -437,6 +440,16 @@ struct ProfileUpdateView: View {
|
|||||||
}
|
}
|
||||||
.edgesIgnoringSafeArea(.bottom)
|
.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)
|
.offset(y: keyboardHandler.keyboardHeight > 0 ? -keyboardHandler.keyboardHeight + 15.3 : 0)
|
||||||
.edgesIgnoringSafeArea(.bottom)
|
.edgesIgnoringSafeArea(.bottom)
|
||||||
@@ -449,6 +462,52 @@ struct ProfileUpdateView: View {
|
|||||||
viewModel.getMyProfile()
|
viewModel.getMyProfile()
|
||||||
}
|
}
|
||||||
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
|
.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
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
68
docs/20260317_이미지등록크롭재구현.md
Normal file
68
docs/20260317_이미지등록크롭재구현.md
Normal file
@@ -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.`
|
||||||
Reference in New Issue
Block a user