Week 5: Assignment
[ 과제 지시사항 ] [ 내 과제 : fcf7350 기준]
# 이모지를 선택하고, 선택 취소하기
- 추가한 이모지를 각각 선택/선택 해제할 수 있도록 하고, 선택 시 원하는 방식으로 표시하는 게 지시 사항이었다! 힌트에
Set
을 사용할 것을 추천해서 처음에는@Staet var selectedEmojis = Set<EmojiArtModel.Emoji>()
로 선언을 했으나 밑에서 다시 설명하겠지만 이것이 모든 비극의 시작이었다...암튼선택 제스처 메커니즘
자체는 단순히TapGesture()
가 발생하면selectedEmojis 세트
에 이모지 유무에 따라 (extension
에서 만든 함수로)toggle
해 주는 방식이다.
struct EmojiArtDocumentView {
...
@State private var selectedEmojis = Set<EmojiArtModel.Emoji>()
private func selectionGesture(on emoji: EmojiArtModel.Emoji) -> some Gesture {
TapGesture()
.onEnded {
withAnimation {
selectedEmojis.toggleMembership(of: emoji)
}
print(selectedEmojis)
}
}
...
}
extension RangeReplaceableCollection where Element: Identifiable {
mutating func remove(_ element: Element) {
if let index = index(matching: element) {
remove(at: index)
}
}
}
extension Set where Element: Identifiable {
mutating func toggleMembership(of element: Element) {
if let index = index(matching: element) {
remove(at: index)
} else {
insert(element)
}
}
}
- 선택 시 효과로는 해당 이모지 테두리에 파란 박스가 나타나게 했다. 처음에는
ZStack
을 생각했다가 파란 박스가 이모지 크기와 같았으면 해서.overlay()
를 사용했고, 보다 깔끔한 코드를 위해 아예 별도의Viewmodifer
로 선언해줬다!
struct SelectionEffect: ViewModifier {
var emoji: EmojiArtModel.Emoji
var selectedEmojis: Set<EmojiArtModel.Emoji>
func body(content: Content) -> some View {
content
.overlay(
selectedEmojis.contains(emoji) ? RoundedRectangle(cornerRadius: 0).strokeBorder(lineWidth: 1.2).foregroundColor(.blue) : nil
)
}
}
extension View {
func selectionEffect(for emoji: EmojiArtModel.Emoji, in selectedEmojis: Set<EmojiArtModel.Emoji>) -> some View {
modifier(SelectionEffect(emoji: emoji, selectedEmojis: selectedEmojis))
}
}
왜 선택되어 있다는데 선택됐다고 표시를 못하니...
- 문제는 이모지를 이동하거나 줌인/아웃 하는 제스처를 추가했을 때 제스처 동안에는 괜찮은데 끝나는 순간 선택되었다는 표시가 사라졌다...분명히
selectedEmojis
자체에는 이모지가 계속 들어있고,viewModifier
적용 순서도selectionEffect
가 제스처 앞이라 대체 뭐가 문제일까 백만번 고민하고,viewModifier
순서도 바꿔보고, 제스처 끝에selectedEmojis 세트
를 초기화 해주고 다시 선택하는 등 별 걸 다 해봤지만...해결이 안돼서 그냥 시뮬레이터 상의 문제 혹은gesture
특인가...라고 생각했지만...
- 응 아니야~ 문제는 내가
selectedEmojis 세트
의element
를EmojiArtModel.Emoji
로 선언했다는 데에 있었다...EmojiArtModel.Emoji
는size
와x, y
좌표를 프로퍼티로 갖기 때문이 줌인/아웃 혹은 이동 이후에는 해당 값이 바뀌므로selectedEMojis
입장에서는 제스처 전과 다른 이모지가 되어버려 더 이상 선택된 이모지로 포함되지 않았던 것...!
- 그래서
selecetedEmojisId = Set<Int>()
를 선언해줘서 선택된 이모지의id
만을 저장해서 크기/위치 변화와 무관하게 이모지의 선택 상태를 관리할 수 있도록 했고,selecteEmojis
는selectedEmojisId
로 부터 도출하는computed property
로 바꿔줬다...!
struct EmojiArtModelDocument {
...
@State private var selectedEmojisId = Set<Int>()
private var selectedEmojis: Set<EmojiArtModel.Emoji> {
var selectedEmojis = Set<EmojiArtModel.Emoji>()
for id in selectedEmojisId {
selectedEmojis.insert(document.emojis.first(where: {$0.id == id})!)
}
return selectedEmojis
}
private func selectionGesture(on emoji: EmojiArtModel.Emoji) -> some Gesture {
TapGesture()
.onEnded {
withAnimation {
selectedEmojisId.toggleMembership(of: emoji.id)
}
}
}
...
}
extension Set where Element == Int {
mutating func toggleMembership(of element: Element) {
if self.contains(element) {
remove(element)
} else {
insert(element)
}
}
}
# 단일탭과 더블탭 제스처가 동시에 존재한다면...?
- 힌트에 의하면 더블탭의 첫번째 탭이 단일탭으로 인식되어 더블탭이 씹히는 것을 방지하기 위해
myDoubleTapGesture().exclusively(before: mySingleTapGesture()
와 같이 선언해줘야 한다..!
# 이모지 Zoom in/out
- 지시사항은 선택된 이모지가 있는 경우 해당 이모지를, 없는 경우
document
전체가 zoom in/out 되도록 할 것!
Zoom in/out 을 어떻게 표현할 것인가
- 가장 먼저했던 고민은 이모지의 경우 각 이모지마다 선택된 횟수가 달라
zoom in/out
된 정도가 다르므로 배경과 달리emojiZoomScale
을 좌표 계산 시에 일괄 적용하는 방식은 불가능하기 때문에zoom
을 어떻게 나타낼 것인가였다.
- 제스처가 끝난 시점에는
ViewModel
의scaleEmoji 함수
를 사용해서 이모지의 크기 프로퍼티 자체를 변경해주면 된다고 생각했고, 문제는 제스처 중에는 어떻게 처리할 것인가였다
- 맨 처음에는 제스처 도중에는 이모지 사이즈 자체를 바꾸기보다는 유저가 이러저리 원하는 사이즈까지 조정해 보는 과정이므로
zoomScale
을 이용해 UI 상에 나타나는 크기를 변화시키는 게 더 말이 된다고 생각해서 그렇게 구현하려다가, 맘대로 안돼서 사이즈를 바꾸는 식으로 갔다가scaleEmoji 함수
가 기존 사이즈에 계속 곱하기를 하는 방식이라 누적되는 문제가 있어서 아예 다른 함수를 짜야해서 결국 처음 생각했던 방식으로 구현했다..!
struct EmojiArtDocumentView {
...
private func zoomGesture() -> some Gesture {
MagnificationGesture()
.updating($gestureZoomScale) { latestGestureScale, gestureZoomScale, _ in
gestureZoomScale = latestGestureScale
}
.onEnded { gestureScaleAtEnd in
if selectedEmojis.isEmpty {
steadyStateZoomScale *= gestureScaleAtEnd
} else {
for emoji in selectedEmojis {
document.scaleEmoji(emoji, by: gestureScaleAtEnd)
}
}
}
}
...
}
경우의 수를 어떻게 적용할 것인가
- 기존의
zoomGesture
함수를 크게 바꾸지 않고 그냥 삼항연산자를 사용해서 적용될scale
을 많이 분기해줬다...!
- 위 코드에서
.updating
안에서 따로 분기해주거나 별도의@GestureState 변수
를 선언하지 않은 이유는 어차피gestureZoomScale 변수
의 경우 제스처 도중에만 효과가 있고 줌/인아웃의 경우 선택된 이모지가 있는지 없는지에 따라서만 분기해주면 되므로 그냥documentBody
에서 선택된 이모지의 유무에 따라 배경이미지와 각 이모지에 어떤steadyStateZoomScale
(제스처에 영향을 받지 않아야 할 때) 과zoomScale
중 무엇이 적용될지를 분기해줬다.if-else
대신ternary
로 분기한 이유는 힌트에서 그게 더 깔끔하다고 했기 떄문...!
struct EmojiArtDocumentView {
...
var documentBody: some View {
GeometryReader { geometry in
ZStack {
Color.white
.overlay(
OptionalImage(uiImage: document.backgroundImage) .scaleEffect(selectedEmojis.isEmpty ? zoomScale : steadyStateZoomScale) // 분기
...
)
...
if document.backgroundImageFetchStatus == .fetching {
ProgressView().scaleEffect(2)
} else {
ForEach(document.emojis) { emoji in
Text(emoji.text)
...
.scaleEffect(getZoomScaleForEmoji(emoji)) // 분기
...
}
}
...
.gesture(zoomGesture().simultaneously(with: gestureEmojiPanOffset == CGSize.zero ? panGesture() : nil))
}
}
...
private func getZoomScaleForEmoji(_ emoji: EmojiArtModel.Emoji) -> CGFloat {
selectedEmojis.isEmpty ? zoomScale : selectedEmojis.contains(emoji) ? zoomScale : steadyStateZoomScale
}
}
# Pan Selected Emoji
- 선택된 이모지를
drag
하는 경우 선택된 이모지들이 모두 이동되어야 하고, 그렇지 않은 경우document
전체가 이동해야 한다! 여기서 관건은 선택된 이모지가 있더라도 선택되지 않은 이모지 위에서drag
하는 경우에는document
전체가 이동해야 한다는 것...!
이모지의 이동을 어떻게 표현할 것인가
zoom in/out
과 마찬가지로 나는 제스처 중에는 결국 목표하는 지점으로 가는 과정이므로 이모지 자체의 좌표가 계속 바뀌기보다는offset
을 이용해서 UI 상에 나타나는 지점만 계속 업데이트 해주는 게 더 자연스럽다고 생각했다.
- 근데 처음에는 잘못 생각해서 현재 있는 함수를 많이 바꾸거나 혹은 같은 함수를 배경 이미지용 따로 이모지용 따로 거의 복붙하는 비효율적인 구조만 가능한 것 같아서 그냥 제스처 중에도
ViewModel
의moveEmoji 함수
를 사용해서 해보려고 했다. 그러나 아무리 해도 이상하게 작동하고, 또 이동 구간을 계속 계산해줘야 되는 게 번거로워서 처음 생각으로 회귀했다.
- 처음에는 별도의
@GestureState 변수
를 선언하지 않아도 분기로 적용 여부를 컨트롤 할 수 있을 줄 알았는데 1. 분기만으로는 해결이 안됐고, 2. 별도로 선언하니 분기 조건이 더 쉬워져서@GestureState private var gestureEmojiPanOffset
을 따로 선언했다.
- 그리고
zoom
과 마찬가지로 개별 이모지마다 이동 정도가 다를 것이기 때문에 제스처가 끝난 시점에는ViewModel
의moveEmoji 함수
를 이용해서 이모지의 좌표 자체를 바꿔줬다!
- 가장 애를 먹었던 건 분기와 더불어 어느 시점에서
gestureEmojiPanOffset 변수
를 적용하는가 였다...처음에는convertToEmojiCoordinates
와converFromEmojiCoordinates
함수 각각에 적용해야 된다고 생각해서 위의 변수가 적용되면 안되는 배경 이미지용convertFromEmojiCoordinates 함수
를 따로 만들었다가position 함수
에서 분기해서 적용해주면 된다는 걸 깨닫고 바꿔줬다!
struct EmojiArtDocumentView {
...
@GestureState private var gestureEmojiPanOffset: CGSize = CGSize.zero
private var panOffset: CGSize {
(steadyStatePanOffset + gesturePanOffset) * zoomScale
}
private func panEmojiGesture(on emoji: EmojiArtModel.Emoji) -> some Gesture {
DragGesture()
.updating($gestureEmojiPanOffset) { latestDragGestureValue, gestureEmojiPanOffset, _ in
gestureEmojiPanOffset = latestDragGestureValue.distance / zoomScale
}
.onEnded { finalDragGestureValue in
for emoji in selectedEmojis {
document.moveEmoji(emoji, by: finalDragGestureValue.distance / zoomScale)
}
}
}
...
private func position(for emoji: EmojiArtModel.Emoji, in geometry: GeometryProxy) -> CGPoint {
if selectedEmojis.contains(emoji) {
return convertFromEmojiCoordinates((emoji.x + Int(gestureEmojiPanOffset.width), emoji.y + Int(gestureEmojiPanOffset.height)), in: geometry)
} else {
return convertFromEmojiCoordinates((emoji.x, emoji.y), in: geometry)
}
}
...
}
경우의 수를 어떻게 적용할 것인가
zoom
보다 고려할 게 훨씬 많았는데 위에서도 언급했듯이 다음과 같이 3가지의 경우를 모두 고려해야했다- 선택된 이모지들이 있고 그 중 하나를 드래그 한 경우 선택된 이모지들만 이동
- 선택된 이모지들이 있지만 선택되지 않은 이모지나 배경 이미지를 드래그 한 경우
document
전체가 이동 - 선택된 이모지가 없는 경우
document
전체가 이동
- 처음에는 제스처 내부에서
if-else
문으로 선택된 이모지가 있고, 해당 이모지에 드래그한 경우에만 제스처가 작동하는 방식 등으로 처리해보려고 했는데else
에 해당해서 아무것도 안하는 제스처를 리턴해야 하는 경우를 처리하는 방법을 못찾았다.
- 그래서
zoom
과 마찬가지로gesture modifier
내부에서 삼항연산자로 분기해줬다...!- 개별 이모지에 드래그 제스처를 했을 때 이모지가 선택되어있는 상태라면
panEmojiGesture
를 실행하게 하고 그 상위의ZStack
(document
전체) 에서는gestureEmojiPanOffset
이 0 이 아닌 경우nil
을 반환하도록 해서 이모지만 이동하도록 하고, - 선택된 이모지가 아닌 경우 이모지 수준에서는
nil
을 반환해서 상위에서panGesture
가 작동해 전체document
를 움직일 수 있도록 했다...! - 사실
gesture modifier
가 삼항연산자들 때문에 깔끔하지 못한 것 같아 별도의 함수로 분기한 결과만을 리턴해보려고 했는데 경우마다 반환 값이 다르다고 안된다고 떠서 실패했다...더 이상 고민할 머리가 없어서 여기까지 하고 일단 보류...
- 개별 이모지에 드래그 제스처를 했을 때 이모지가 선택되어있는 상태라면
struct EmojiArtDocumentView: View {
...
var documentBody: some View {
GeometryReader { geometry in
ZStack {
Color.white
.overlay(
OptionalImage(uiImage: document.backgroundImage)
...
)
.gesture(doubleTapToZoom(in: geometry.size).exclusively(before: tapToUnselectAllEmojis())) // 분기
if document.backgroundImageFetchStatus == .fetching {
ProgressView().scaleEffect(2)
} else {
ForEach(document.emojis) { emoji in
Text(emoji.text)
...
.gesture(selectionGesture(on: emoji).simultaneously(with: longPressToDelete(on: emoji).simultaneously(with: selectedEmojis.contains(emoji) ? panEmojiGesture(on: emoji) : nil))) // 분기
...
}
}
}
...
.gesture(zoomGesture().simultaneously(with: gestureEmojiPanOffset == CGSize.zero ? panGesture() : nil)) // 분기
}
}
...
}
# 이모지 지우기
-
어떤 방식으로 유저가 이모지를 지울 수 있게 할 지는 자유라고 했기 때문에 어떤 제스처를 쓸지부터가 엄청나게 고민이 됐다. 자유 멈춰....
- 이미 다른 용도로 사용하고 있는 제스처를 또 쓰는 건 유저 입장에서 직관적이지 못하다고 생각해 다른 제스처를 쓰고 싶어서
LongPressedGesture
를 썼고, - 실수로 잘못 눌렀는데 지워지면 빡칠 것 같아 제스처가 발생하면 바로 이모지를 지우기 보다 알림창 등을 통해서 지울 것인지 확인하는 방식으로 구성했다.
- 이미 다른 용도로 사용하고 있는 제스처를 또 쓰는 건 유저 입장에서 직관적이지 못하다고 생각해 다른 제스처를 쓰고 싶어서
-
그래서 이모지를 길게 누르면
longPressToDelete 함수
에서@State var showDeleteAlert
를 토글하게 하고 이 변수를.alert 메서드
에 바인딩해줘서 알림창이 뜨도록 구성해줬다.
- 사실 처음에는
.alert(_ title: Text, isPresented: Binding<Bool>, actions: () -> View)
형태의 메서드를 사용했는데actions
내부에서 현재 누른 이모지에 접근할 방법이 없어서 별 생각 없이 각 이모지View
에alert 메서드를
달아줬다. 그런데 이렇게 했더니 리팩토링할 때 어떤 이모지인지 인자로 보낼 수 없는 게 첫번째 문제였고, 기껏 리팩토링했더니 두 번째로는 자꾸 길게 누른 이모지가 아니라 다른 이모지가 삭제됐다...(욕)
// 문제가 많았던 원래 버전
struct EmojiArtDocumentView: View {
...
var documentBody: some View {
GeometryReader { geometry in
ZStack {
...
if document.backgroundImageFetchStatus == .fetching {
ProgressView().scaleEffect(2)
} else {
ForEach(document.emojis) { emoji in
if #available(iOS 15.0, *) {
Text(emoji.text)
.gesture(longPressToDelete(on: emoji).simultaneously(with: selectionGesture(on: emoji).simultaneously(with: selectedEmojis.contains(emoji) ? panEmojiGesture(on: emoji) : nil)))
.alert(Text("Delete?"), isPresented: $showDeleteAlert) {
Button(role: .destructive) {
withAnimation {
document.removeEmoji(emoji)
}
} label: {
Text("Yes")
}
}
}
}
}
...
}
- 찾아보니
alert 메서드
중에actions 클로저
가 인자를 받도록 할 수 있는.alert(_ title: Text, isPresented: Binding<Bool>, presenting: T?, actions: (T) -> View)
버전이 있었다.- 참고로 이 버전의 경우
isPresented
가true
고,presenting
이nil
이 아닐 때 알림창이 나타난다...! - 그래서
@State var deleteEmoji(on emoji: EmojiArtModel.Emoji)
를 선언해서longPressToDelete 함수
에서 만약에 유효한long press
가 발생하면deleteEmoji
를 현재emoji
로 업데이트 해주고,showDeleteAlert
도 토글했다 - 그리고
.alert
에서presenting 인자
로deleteEmoji
를 받고actions 클로저
의 인자로도 보내줬더니 누른 이모지가 잘 삭제됐다!!! - 이렇게 하니까 굳이 각 이모지
View
밑에.alert
를 달아줄 필요가 없어서, 전체ZStack
에 달아줬다...! 왜냐하면 알림창 자체는 전체 화면에 대한viewModifier
로 작동하는 게 더 자연스럽다고 생각했기 때문...!
- 참고로 이 버전의 경우
struct EmojiArtDocumentView: View {
@ObservedObject var document: EmojiArtDocument
var documentBody: some View {
GeometryReader { geometry in
if #available(iOS 15.0, *) {
ZStack {
...
}
...
.gesture(zoomGesture().simultaneously(with: gestureEmojiPanOffset == CGSize.zero ? panGesture() : nil))
.alert("Delete", isPresented: $showDeleteAlert, presenting: deleteEmoji) { deleteEmoji in
deleteEmojiOnDemand(for: deleteEmoji)
}
}
...
}
}
...
@State private var showDeleteAlert = false
@State private var deleteEmoji: EmojiArtModel.Emoji?
private func longPressToDelete(on emoji: EmojiArtModel.Emoji) -> some Gesture {
LongPressGesture(minimumDuration: 1.2)
.onEnded { LongPressStateAtEnd in
if LongPressStateAtEnd {
deleteEmoji = emoji
showDeleteAlert.toggle()
} else {
deleteEmoji = nil
}
}
}
@available(iOS 15.0, *)
private func deleteEmojiOnDemand(for emoji: EmojiArtModel.Emoji) -> some View {
Button(role: .destructive) {
if selectedEmojis.contains(emoji) { selectedEmojisId.remove(emoji.id) }
document.removeEmoji(emoji)
} label: { Text("Yes") }
}
}
- 하나 해결 못한 건 위에서
.alert
가 마지막에actions
클로저를trailing closure
형태로 받고 있는데, 그냥 클로저를 바로 넣으려고 하면 계속 에러가 떠서 일단 이런 형태로 타협...
☀️ 느낀점
- 필수 요구사항 중에 2 항목 빼고 나머지 구현하는 데 1시간 밖에 안 걸려서 오 삼수강한 보람이 있다...금방 하겠다....! 라고 생각했으나 결국 하루 꼬박 걸려서 겨우 끝냈다...^^ 위에는 삽질한 걸 다 축약해서 썼지만...이게 축약한 거라니...암튼 사실상 멍청연대기...ㅎㅎㅎ
- 이번 과제에서는 그래도 코드를 보다 깔끔하게 짜려는 노력을 많이 했고, 꽤 깔끔한 것 같아 기분이 좋다...! 이런 저런 문제가 아직도 남아있고 처음으로 보너스는 손도 안댔지만 일단 더이상 5주차에 머물러 있기 싫어서 넘어가야지...완강하고 돌아와서 보너스 한개라도 깨봐야겠다...
- 그리고 무엇보다 이제는 원하는대로 제스처를 어느정도 구현할 수 있을 거 같다! 보너스하면서 좀 더 섬세한 조정도 해 보는 게 목표!
Author And Source
이 문제에 관하여(Week 5: Assignment), 우리는 이곳에서 더 많은 자료를 발견하고 링크를 클릭하여 보았다 https://velog.io/@sunnysideup/Week-5-Assignment저자 귀속: 원작자 정보가 원작자 URL에 포함되어 있으며 저작권은 원작자 소유입니다.
우수한 개발자 콘텐츠 발견에 전념 (Collection and Share based on the CC Protocol.)