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 세트elementEmojiArtModel.Emoji 로 선언했다는 데에 있었다...EmojiArtModel.Emojisizex, y 좌표를 프로퍼티로 갖기 때문이 줌인/아웃 혹은 이동 이후에는 해당 값이 바뀌므로 selectedEMojis 입장에서는 제스처 전과 다른 이모지가 되어버려 더 이상 선택된 이모지로 포함되지 않았던 것...!
  • 그래서 selecetedEmojisId = Set<Int>() 를 선언해줘서 선택된 이모지의 id 만을 저장해서 크기/위치 변화와 무관하게 이모지의 선택 상태를 관리할 수 있도록 했고, selecteEmojisselectedEmojisId 로 부터 도출하는 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 을 어떻게 나타낼 것인가였다.
  • 제스처가 끝난 시점에는 ViewModelscaleEmoji 함수 를 사용해서 이모지의 크기 프로퍼티 자체를 변경해주면 된다고 생각했고, 문제는 제스처 중에는 어떻게 처리할 것인가였다
  • 맨 처음에는 제스처 도중에는 이모지 사이즈 자체를 바꾸기보다는 유저가 이러저리 원하는 사이즈까지 조정해 보는 과정이므로 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 상에 나타나는 지점만 계속 업데이트 해주는 게 더 자연스럽다고 생각했다.
  • 근데 처음에는 잘못 생각해서 현재 있는 함수를 많이 바꾸거나 혹은 같은 함수를 배경 이미지용 따로 이모지용 따로 거의 복붙하는 비효율적인 구조만 가능한 것 같아서 그냥 제스처 중에도 ViewModelmoveEmoji 함수 를 사용해서 해보려고 했다. 그러나 아무리 해도 이상하게 작동하고, 또 이동 구간을 계속 계산해줘야 되는 게 번거로워서 처음 생각으로 회귀했다.
  • 처음에는 별도의 @GestureState 변수 를 선언하지 않아도 분기로 적용 여부를 컨트롤 할 수 있을 줄 알았는데 1. 분기만으로는 해결이 안됐고, 2. 별도로 선언하니 분기 조건이 더 쉬워져서 @GestureState private var gestureEmojiPanOffset 을 따로 선언했다.
  • 그리고 zoom 과 마찬가지로 개별 이모지마다 이동 정도가 다를 것이기 때문에 제스처가 끝난 시점에는 ViewModelmoveEmoji 함수 를 이용해서 이모지의 좌표 자체를 바꿔줬다!
  • 가장 애를 먹었던 건 분기와 더불어 어느 시점에서 gestureEmojiPanOffset 변수 를 적용하는가 였다...처음에는 convertToEmojiCoordinatesconverFromEmojiCoordinates 함수 각각에 적용해야 된다고 생각해서 위의 변수가 적용되면 안되는 배경 이미지용 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가지의 경우를 모두 고려해야했다
    1. 선택된 이모지들이 있고 그 중 하나를 드래그 한 경우 선택된 이모지들만 이동
    2. 선택된 이모지들이 있지만 선택되지 않은 이모지나 배경 이미지를 드래그 한 경우 document 전체가 이동
    3. 선택된 이모지가 없는 경우 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 내부에서 현재 누른 이모지에 접근할 방법이 없어서 별 생각 없이 각 이모지 Viewalert 메서드를 달아줬다. 그런데 이렇게 했더니 리팩토링할 때 어떤 이모지인지 인자로 보낼 수 없는 게 첫번째 문제였고, 기껏 리팩토링했더니 두 번째로는 자꾸 길게 누른 이모지가 아니라 다른 이모지가 삭제됐다...(욕)
// 문제가 많았던 원래 버전
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) 버전이 있었다.
    • 참고로 이 버전의 경우 isPresentedtrue 고, presentingnil 이 아닐 때 알림창이 나타난다...!
    • 그래서 @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주차에 머물러 있기 싫어서 넘어가야지...완강하고 돌아와서 보너스 한개라도 깨봐야겠다...
  • 그리고 무엇보다 이제는 원하는대로 제스처를 어느정도 구현할 수 있을 거 같다! 보너스하면서 좀 더 섬세한 조정도 해 보는 게 목표!

좋은 웹페이지 즐겨찾기