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

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

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

View File

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

View File

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

View File

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