-
[아이매드] - SwiftUI로 사진 선택 후 이미지 규격 변경하기프로젝트/아이매드 2024. 7. 17. 16:10728x90반응형
요즘 팀 프로젝트 배포에 많은 신경을 쏟고 있다.
최대한 많은 기술을 도전해 보고 실제 앱에 적용 시키려고 노력하고 있는데,
오늘은 여러 앱에서 프로필 사진을 등록할 때 나오는 사진 선택 후 규격에 맞게 자르고 서버에 전송하는 것까지 포스팅 해보려고 한다.
카카오 & 인스타그램 프로필 선택 화면 우리 프로젝트는 프로필을 선택하는 기능이 있는데, 타사 서비스 처럼 프로필을 선택하는 로직을 직접 구현해보고 싶었다.
https://www.youtube.com/watch?v=1Fz86eQjxus
이 영상을 참고해 제작했다.
코드 내용은 영상과 거의 동일 하지만 우리 서비스에 맞게 조금씩 코드를 수정하고, 코드를 이해하는데 시간이 많이 소요됐다.
코드를 하나씩 짚어보면서 원리를 파악하자
아이매드 프로젝트에서는 프로필 선택 모드가 총 두가지가 있다.
1. 앱에 존재하는 6가지의 기본 이미지
2. 디바이스에 내장되어 있는 갤러리 사진
프로필을 처음 선택하거나 프로필을 변경하려할 시 confirmdialog로 사용자에게 1번 혹은 2번모드를 선택할 것을 유도하고 갤러리버튼을 누를 시 갤러리 화면을 띄우는 로직이다.
기본 프로필 이미지들 중 하나를 선택하면, 이미지를 터치하면 자동으로 프로필 이미지를 서버로 전송하게 된다.
갤러리에서 이미지를 선택하게 되면, 정해진 프레임에 맞춰 보여질 부분 혹은 크기를 설정해 확인하면 사용자가 커스텀한 사진을 서버로 전송하게 된다.
이미지 크기는 128*128 jpg형태로 통일한다.
개발 순서대로 설명하자면 먼저 사진 자르기 화면을 구현한다.
@ViewBuilder func imageView(_ hideGrids:Bool = false)->some View{ // isPad는 현재 디바이스가 아이패드인지 아닌지를 판단해 Bool값을 반환해 주는 유틸리티 메서드 let cropSize = CGSize(width: isPad() ? 500 : mainWidth, height: isPad() ? 500 : mainWidth) GeometryReader { geometry in let size = geometry.size if let image{ Image(uiImage: image) .resizable() .scaledToFit() .overlay{ GeometryReader{ proxy in let rect = proxy.frame(in: .named("CROPVIEW")) Color.clear .onChange(of: isInteracting){ newValue in withAnimation(.easeInOut(duration: 0.2)){ if rect.minX > 0{ offset.width = (offset.width - rect.minX) haptics(.medium) } if rect.minY > 0{ offset.height = (offset.height-rect.minY) haptics(.medium) } if rect.maxX < size.width{ offset.width = (rect.minX - offset.width) haptics(.medium) } if rect.maxY < size.height{ offset.height = (rect.minY - offset.height) haptics(.medium) } } if !newValue{ lastStoreOffset = offset } withAnimation(.easeInOut(duration: 0.5)){ appearGrid.toggle() } } } } .frame(size) } } .scaleEffect(scale) .offset(offset) .overlay { if !hideGrids{ if appearGrid{ Grids() } } } .coordinateSpace(name:"CROPVIEW") .gesture( DragGesture() .updating($isInteracting){_,out,_ in out = true } .onChanged{ value in let translation = value.translation offset = CGSize(width: translation.width + lastStoreOffset.width, height: translation.height + lastStoreOffset.height) } ) .gesture( MagnificationGesture() .updating($isInteracting){_,out,_ in out = true }.onChanged{ value in let updatedScale = value + lastScale scale = (updatedScale < 1 ? 1:updatedScale) } .onEnded{ value in withAnimation(.default){ if scale < 1{ scale = 1 lastScale = 0 }else{ lastScale = scale-1 } } } ) .frame(cropSize) }
검은 화면 중간에 위치한 이미지를 나타내는 뷰 메서드다.
이 이미지를 커스텀할 때 사용하는 제스쳐는 두가지 이다.
핀치 인&아웃 (scale): 줌 인&아웃으로 사진의 크기를 조절하는 기능 .scaleEffect()의 값이 이 제스쳐의 변화량에 따라 업데이트됨
드래그 (offset): 사진의 위치를 조절하는 기능 .offset()의 값이 이 제스쳐의 변화량에 따라 업데이트됨
그리고 두 가지의 .gesture() 메서드에서 각각의 제스쳐 변화량을 감지하여 상태변수를 업데이트 하는 이벤트를 처리한다.
코드를 자세하게 해설하지 않겠지만, 대략적으로는 이렇다.
핀치 인&아웃 : 현제 이미지 크기에서 사용자가 핀치를 한 변화량을 계산해 기존 이미지 크기에 값을 더해준다. 여기서 최소 값을 지정해서 만약 그 값보다 핀치 아웃한 값이 더 작아질 경우 다시 원래대로 크기를 되돌린다.
.gesture( MagnificationGesture() .updating($isInteracting){_,out,_ in out = true }.onChanged{ value in let updatedScale = value + lastScale scale = (updatedScale < 1 ? 1:updatedScale) } .onEnded{ value in withAnimation(.default){ if scale < 1{ scale = 1 lastScale = 0 }else{ lastScale = scale-1 } } } )
드래그 : 핀치와 마찬가지로 사용자가 이미지를 드래그해 이동한 변화량을 현재 이미지가 있는 좌표값에 더해준다.
.gesture( DragGesture() .updating($isInteracting){_,out,_ in out = true } .onChanged{ value in let translation = value.translation offset = CGSize(width: translation.width + lastStoreOffset.width, height: translation.height + lastStoreOffset.height) } )
여기서 한 기능이 더 추가되는데, 만약 정해진 프레임 안쪽으로 사진을 드래그 한 경우 그 프레임 크기에 맞춰서 다시 사진을 되돌아 오도록 구현해야한다.
위의 부분은 GeometryReader 내부에서 처리되는데,
Color.clear .onChange(of: isInteracting){ newValue in withAnimation(.easeInOut(duration: 0.2)){ if rect.minX > 0{ offset.width = (offset.width - rect.minX) haptics(.medium) } if rect.minY > 0{ offset.height = (offset.height-rect.minY) haptics(.medium) } if rect.maxX < size.width{ offset.width = (rect.minX - offset.width) haptics(.medium) } if rect.maxY < size.height{ offset.height = (rect.minY - offset.height) haptics(.medium) } } if !newValue{ lastStoreOffset = offset } withAnimation(.easeInOut(duration: 0.5)){ appearGrid.toggle() } }
onChange는 뷰 없이 독자적으로 존재할 수 없기 때문에 투명색의 뷰인 Color.clear를 사용했다.각 offset의 위치가 해당 프레임 규격을 넘어설 경우 제자리로 돌아오는데. 이미지 좌표는 상태변수이기 때문에 사용자가 뷰를 움직이는 순간 값이 업데이트 된다. 그래서 현재 뷰의 좌표에서 움직인 이동거리와의 차를 구하면 원래 있던 곳으로 뷰가 다시 이동하는 것이다.
이 때 애니메이션 효과를 주지 않으면 뷰가 순간이동 하는 것처럼 뚝뚝 끊키니 주의하자.
그리고 이미지 뷰의 이동을 감지해 깨알같이 흰색 그리드가 추가되는 효과를 삽입했다.
@ViewBuilder func Grids()->some View{ ZStack{ HStack{ ForEach(1...4,id: \.self){ _ in Rectangle() .foregroundColor(.white) .frame(width: 1) .frame(maxWidth:.infinity) } } VStack{ ForEach(1...4,id: \.self){ _ in Rectangle() .foregroundColor(.white) .frame(height: 1) .frame(maxHeight:.infinity) } } } }
이제 이미지 조정이 끝난 후 체크버튼을 눌러 만든 커스텀 이미지를 추출하는 이벤트이다.
var onCrop:(UIImage?,Bool)->() . . . . let renderer = ImageRenderer(content: imageView(true)) renderer.proposedSize = .init(CGSize(width: isPad() ? 500 : mainWidth, height: isPad() ? 500 : mainWidth)) if let image = renderer.uiImage{ onCrop(image,true) }else{ onCrop(nil,false) }
이미지에서 그리드를 제외하고 정해진 프레임 규격만큼의 사진을 자른다.
만약 이미지가 존재할 경우 이미지를 클로져 파라미터로 반환하고 아닐 경우 nill을 반환한다.
그렇게 만든 뷰로 @ViewBuilder타입의 구조체를 만들어줬다.
struct CustomImagePicker<Content:View>: View { var content:Content @Binding var show:Bool @Binding var croppedImage:UIImage? init(show:Binding<Bool>,croppedImage:Binding<UIImage?>,@ViewBuilder content: @escaping ()->Content) { self.content = content() self._show = show self._croppedImage = croppedImage } @State var showCropView = false @State var photosItem:PhotosPickerItem? @State var selected:UIImage? var body: some View { content .photosPicker(isPresented: $show, selection: $photosItem) .onChange(of: photosItem) { newValue in if let newValue{ Task{ if let imageData = try? await newValue.loadTransferable(type: Data.self),let image = UIImage(data: imageData){ await MainActor.run { self.selected = image showCropView.toggle() } } } } } .fullScreenCover(isPresented: $showCropView){ selected = nil photosItem = nil } content: { CropView(image: $selected){ croppedImage, status in if let croppedImage{ self.croppedImage = croppedImage } } } } }
이 부분은 실제로 아까 설명한 사진자르기 뷰와 그 뷰를 호출하는 부분의 중간 어뎁터라고 생각하면 된다.
사진 자르기화면에서 반환한 이미지를 그대로 받아 Data형태로 디코딩해 이 컴포넌트를 호출하는 뷰에 바인딩해준다.
이 컴포넌트가 appear될때 실행 순서는 다음과 같다.
1. .photoPicker 메서드로 갤러리 화면을 띄운다.
2. 사진을 선택하게 되면 자동적으로 photosItem 상태변수가 업데이트 된다.
3. photosItem값이 업데이트 되면 그 값을 Data -> UIImage형으로 디코딩해 다시 photosItem 상태변수에 저장
4. uiImage를 CropImage로 넘겨주고 아까 처음에 설명한 사진 자르기 뷰에서 사진을 조정한다.
5. 사진 자르기 뷰에서 완성된 사진을 croppedImage에 저장해주고 사진자르기 뷰가 사라졌음을 감지해 photosItem, photosItem 데이터를 각각 null로 초기화함
6. 이 컴포넌트를 호출하는 뷰와 데이터가 바인딩되어있기 때문에 호출부에선 croppedImage를 받음과 동시에 바로 사용할 수 있게됨
내용이 워낙 복작해서 말로 설명하기가 매우 힘든 부분이였다.
혹시 이해가 안가거나 더 궁금한게 있으신 분은 댓글을 달아주길 바란다.
'프로젝트 > 아이매드' 카테고리의 다른 글
[아이매드] - SwiftUI로 StickyView 만들기 (0) 2024.07.28 [아이매드]- 배포 후 두번째 리젝(Guideline 1.2 - Safety - User-Generated Content, Guideline 1.5 - Safety) (1) 2024.07.24 [아이매드]- 배포 후 첫번째 리젝(Guideline 5.1.1 - Legal - Privacy - Data Collection and Storage) (0) 2024.07.24 [아이매드]- 배포 후 첫번째 리젝(Guideline 1.2 - Safety - User-Generated Content) (0) 2024.07.24 [아이매드] - SwiftUI 더보기 버튼 (1) 2023.04.18