[ 과제 지시사항 ] [ 내 과제 : 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()
와 같이 선언해줘야 한다..!document
전체가 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
}
}
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
형태로 받고 있는데, 그냥 클로저를 바로 넣으려고 하면 계속 에러가 떠서 일단 이런 형태로 타협...