이모지 아트에는 이모지 외에도 배경을 drag & drop 할 수 있으므로 이미지나 url이 drop
되었을 때 이를 받을 수 있어야 한다
이를 위해서 기존 drop 함수
에 found
변수를 새로 선언해서 provider
가 이미지, url, 이모지 중 무엇을 담고 있는지 순차적으로 확인한다! 강의를 듣고 나서 혼자 코드를 짜면서 왜 View
에서는 Image
, URL
, String
을 받게 될까가 좀 고민이었는데 생각해보니까 UI
는 당연히 우리가 아는 형태로 유저가 보낸 데이터를 받고,Model
은 해당 데이터를 UI
표현 방식과 무관한, 자신이 보관하기 가장 좋은 형태로 바꾸어 저장하며, ViewModel
이 가운데에서 View
와 Model
이 원활하게 소통할 수 있도록 적절한 방식으로 데이터를 가공해주는 거였다...뭔가 MVVM 에 대해 감이 잡히는 것 같다...!
View
에서는 Image
의 형태로, ViewModel
에서는 UIImage
의 형태로 그리고 Model
에서는 이미지를 나타내는 URL
이나 Data
의 형태로 보관하고 있는 것...! struct EmojiArtDocumentView: View {
private func drop(provider: [NSItemProvider], at location: CGPoint, in geometry: GeometryProxy) -> Bool {
// returns whether it was able to load that object
var found = provider.loadObjects(ofType: URL.self) { url in
// some code
}
if !found {
found = provider.loadObjects(ofType: UIImage.self) { image in
...
}
}
if !found {
// if the provider contains string return true
found = provider.loadObjects(ofType: String.self) { string in
if let emoji = string.first, emoji.isEmoji {
document.addEmoji(
String(emoji),
at: convertToEmojiCoordinates(location, in: geometry),
size: defaultEmojiFontSize / zoomScale
)
}
}
}
return found
}
}
이미지 url
만 추출하는 과정이 필요하다! 즉 imgurl
을 찾는 것인데, 이게 바로 url.imageURL
이 하는 역할이다!var found = provider.loadObjects(ofType: URL.self) { url in document.setBackground(.url(url.imageURL)) }
imgurl
의 경우 imgurl=some%url%to%image
와 같은 형식으로 되어 있으므로&
를 기준으로 분절한 다음 쿼리별로 imgurl
인지 체크하기 위해 =
로 구분해서 2개로 나뉘고 첫번째 원소가 imgurl
이라면 %
를 제거해서 imgurl
을 추출하는 것 같다!extension URL { var imageURL: URL { // 불필요한 정보 제거 for query in query?.components(separatedBy: "&") ?? [] { let queryComponents = query.components(separatedBy: "=") if queryComponents.count == 2 { if queryComponents[0] == "imgurl", let url = URL(string: queryComponents[1].removingPercentEncoding ?? "") { return url } } } // if the URL itself is absolute, baseURL is nil return baseURL ?? self } }
jpeg
형식으로 바꿔줘야 하는데 다행히 이 친구는 내장함수가 있다!if !found { found = provider.loadObjects(ofType: UIImage.self) { image in if let data = image.jpegData(compressionQuality: 1.0 ) { document.setBackground(.imageData(data)) } } }
배경화면에 이미지를 드래그 & 드랍하면 Model
에는 해당 이미지의 URL
이나 Image Data
가 저장된다. View
에서 이미지를 나타내려면 UIImage
를 Image
로 변환하는 작업이 필요하므로 ViewModel
에서 Model
의 데이터를 이용해 UIImage
를 구성한다...!
배경화면 이미지가 바뀔 때마다 View
에 알릴 필요가 있으므로 @Published var BackgroundImage: UIImage?
와 같이 선언한다! 이때 url
로부터 이미지를 불러오는 과정을 시간이 걸릴 수 있기 때문에 computed var
로 선언할 수 없고, 매번 set
해줘야 하므로 property observer
인 didSet
을 사용해 Model
에서 배경화면이 바뀔 때마다 업데이트하도록 한다...!
url
로부터 이미지를 불러오는 데 실패하는 등 에러가 있을 수 있기 때문class EmojiArtDocument: ObservableObject {
@Published private(set) var emojiArt: EmojiArtModel {
didSet {
if emojiArt.background != oldValue.background {
fetchBackgroundImageDataIfNecessary()
}
}
}
//syntatic sugar
...
var background: EmojiArtModel.Background { emojiArt.background }
// MARK: - Background
@Published var backgroundImage: UIImage?
...
private func fetchBackgroundImageDataIfNecessary() {
// some code...
}
...
}
url
로부터 이미지를 가져오는 것은 오래걸릴 수 있기 때문에 아래와 같이 백그라운드 큐에서 작업을 수행해야 한다UI
에 영향을 미치므로 이미지를 불러온 다음의 일은 메인 큐에서 수행해야 한다...!...
private func fetchBackgroundImageDataIfNecessary() {
backgroundImage = nil
switch emojiArt.background {
case .url(let url):
// fetch the url
backgroundImageFetchStatus = .fetching
DispatchQueue.global(qos: .userInitiated).async {
let imageData = try? Data(contentsOf: url)
DispatchQueue.main.async {
if imageData != nil {
self.backgroundImage = UIImage(data: imageData!)
}
}
}
case .imageData(let data):
backgroundImage = UIImage(data: data)
case .blank:
break
}
}
...
fetchBackgroundImageDataIfNecessary 함수
에서 메인큐에 backgroundImage
를 넣으면 Reference to property 'backgroundImage' in closure requires explicit use of 'self' to make capture semantics explicit
와 같은 에러와 수정 제안이 뜬다... closure
와 이를 담고 있는 우리의 ViewModel(여기서 self.)
이 모두 Reference Type
이므로 strong reference cycle
을 방지하기 위해 명시적으로 self.
를 붙이면서 확인하라는 뜻이다...일단 backgroundImage
앞에 self.
을 붙이면 해결이 되는 듯 보이지만 실제로는 앞에서 말한 strong reference cycle
이 존재하는 상태다...
reference type
의 경우 어떤 함수 / 클로저 / 상수 / 변수 중 하나라도 해당reference type
을 담고 있다면 계속 메모리 상에 저장된다
reference type
이므로 let imageData = try? Data(contentsOf: url)
줄의 작업이 끝날때까지 메모리에 저장된다, 마찬가지로 우리의 ViewModel
도 reference type
이므로 메모리에 저장되는데 self.
으로 인해 메모리 상에 있는 메인 큐의 인자 클로저가 우리의 ViewModel
을 가리키게 되어 strong reference
가 생기므로 파일을 닫더라도 힙에 ViewModel
이 남아있게 된다...즉, strong reference cycle
이 존재하는 상태이다클래스의 새로운 인스턴스를 생성할 때마다
ARC(Automatic Refernce Cycle)
는 해당 인스턴스를 저장하기 위한 메모리를 할당하며, 해당 메모리는 해당 인스턴스를 가리키는 프로퍼티 / 변수 / 상수가 하나라도 존지하는 한 프리되지 않는데 이를strong reference
라고 한다. 어떤strong reference
가 0이 되는 경우가 없을 때, 즉 언제나 하나 이상의 프로퍼티 / 변수 / 상수가 해당 인스턴스를 가리키는 경우strong reference cycle
이 발생했다고 한다. 킹갓공식문서
strong reference cycle
은 클로저 정의 시 클로저가 reference type
을 내부에 캡쳐할 때 따르는 규칙을 담고 있는 capture list
를 함께 정의함으로써 해결한다! weak
혹은 unowned
가 되도록 정의해주면 된다...!weak
: 인스턴스가 먼저 프리되어 nil이 될 때 사용unowned
: 클로저와 인스턴스가 항상 서로를 가리키고, 동시에 프리될 때 사용weak
을 이용해서 해당 클로저 내부에서만 해당 변수를 재정의해서 이 문제를 해결할 수 있다...! weak
을 붙이면 옵셔널이 되어 다른 누구도 힙에 해당 변수를 보관하고 있지 않다면 더 이상 힙에 저장하지 않고 nil
로 바꿔준다...! private func fetchBackgroundImageDataIfNecessary() {
backgroundImage = nil
switch emojiArt.background {
case .url(let url):
// fetch the url
DispatchQueue.global(qos: .userInitiated).async {
let imageData = try? Data(contentsOf: url)
DispatchQueue.main.async { [weak self] in // weak 선언
if imageData != nil {
self?.backgroundImage = UIImage(data: imageData!)
}
}
}
case .imageData(let data):
backgroundImage = UIImage(data: data)
case .blank:
break
}
}
작업을 요청 시의 세계와 수행 시의 세계가 같을 때만 작업을 수행하는 것이 **비동기 처리**의 핵심..!
if self?.emojiArt.background == EmojiArtModel.Background.url(url)
을 이용해서 Model
의 배경화면과 현재 불러온 url
의 배경화면이 일치하는 지 확인! private func fetchBackgroundImageDataIfNecessary() {
backgroundImage = nil
switch emojiArt.background {
case .url(let url):
// fetch the url
DispatchQueue.global(qos: .userInitiated).async {
let imageData = try? Data(contentsOf: url)
DispatchQueue.main.async { [weak self] in // weak 선언
if self?.emojiArt.background == EmojiArtModel.Background.url(url) { // 환경이 같은 지 확인
if imageData != nil {
self?.backgroundImage = UIImage(data: imageData!)
}
}
}
}
....
}
.gesture
view modifier
사용myView.gesture(theGesture)
theGesture
는 Gesture 프로토콜
을 따르며 함수 / 연산 프로퍼티 / View
의 body var
내부의 지역변수 로 생성됨var theGesture: some Gesture { return TapGesture(count: 2) }
.onEnded { }
사용var theGesture: some Gesture {
return TapGesture(count: 2)
.onEnded { // do sth }
}
convenience versions
가 존재myView.onTapGesture(count: Int) { /* do sth */ }
DragGesture
, Magnification Gesture
, RotationGesture
, LongPressGesture(i.e. fingers down and fingers up
.onEnded
를 사용하는 것은 같으나 아래 슬라이드와 같이 value
인자가 생김...! value
는 해당 제스처가 끝났을 때의 상태를 알려주며 제스처 종류에 따라 상이DragGesture
: 손가락의 시작 지점과 끝 지점 등을 담고 있는 구조체value
에 따라 @GestureState
를 이용해서 제스처의 상태를 계속 업데이트함으로써 제스처 시행 도중에도 반응을 할 수 있음...! 단 제스처가 종료된 후에는 <starting value>
로 돌아감@GestureState var myGestureState: MyGestureStateType = <starting value>
.updating
안에 @GestureState
프로퍼티 래퍼가 붙은 변수 앞에 $
표시를 붙여서 사용...!.onChanged
라는 단순한 버전이 있긴 한데 얘는 과정을 나타내지는 못하고, 제스처의 결과로 나타낼 반응이 손가락의 절대적인 위치와 관련이 있을 때(e.g. 유저가 손가락을 펜처럼 사용하는 경우) 유용하다. 그러나 상대적인 위치에 반응하는 경우(e.g. 드래그한 정도)에는 .updating()
이 적절하다...! 아래 슬라이드 참고..!먼저 gesture
를 리턴하는 doubleTapToZoom() 함수
를 만들고, zoomToFit() 함수
를 만들어서 doubleTapToZoom() 함수
가 호출되었을 때 어떠한 동작을 하게 할 지 지정해줬다.
zoomToFit() 함수
에서 도출한 zoom
한 상태의 길이와 너비를 어떻게 배경이미지에 적용해야할 지 어려웠는데 @State var scale: CGFloat
을 선언하고 배경 이미지에 .scaleEffect(zoomScale)
로 적용한 다음 얘를 계속 업데이트 해주는 방식이었다...!
struct EmojiArDocumentView {
var documentBody: some View {
GeometryReader { geometry in
ZStack {
Color.white
.overlay(
OptionalImage(uiImage: document.backgroundImage)
.position(convertFromEmojiCoordinates((0, 0), in: geometry))
.scaleEffect(zoomScale) // 여기 적용
)
.gesture(doubleTapToZoom(in: geometry.size)) // 제스처!
...
}
// MARK: - Zoom
@State var zoomScale: CGFloat = 1
private func doubleTapToZoom(in size: CGSize) -> some Gesture {
TapGesture(count: 2)
.onEnded {
withAnimation {
zoomToFit(image: document.backgroundImage, in: size)
}
}
}
private func zoomToFit(image: UIImage?, in size: CGSize) {
if let image = image, image.size.height > 0, image.size.width > 0,
size.width > 0, size.height > 0 {
let hZoom = size.width / image.size.width
let vZoom = size.height / image.size.height
zoomScale = min(hZoom, vZoom)
}
}
}
View
는 디폴트로 자신에게 주어진 영역 밖을 침범할 수 있으므로 .clipped() modifier
를 이용하면 해결된다...! zoom in/out
하는 동안 계속 zoom 정도
에 따라 이미지들의 크기가 업데이트 되도록 하는 제스처를 만들어야 한다. .updating
을 사용하면 인자로 가장 최근의 제스처 값이 알아서 들어와서 제스처 중에는 gestureZoomScale
을, 제스처가 끝났을 때는 steadyStateZoomScale
을 업데이트 해주면 됐다...! inout 매개변수
인데 일종의 syntatic sugar로 그냥 바꾸려고 하는 변수의 이름을 그대로 사용했다..!zoomScale
를 연산 변수로 지정해줬다...! 이러면 제스처 중에는 gestureZoomScale
이 반영되고, 제스처가 끝나면 gestureZoomScale
은 다시 초기값인 1
로 돌아가 영향력이 없어지므로 steadyStateZoomScale
과 같아진다...!zoom
이 영향을 줄 수 있는 곳들에 다 적절한 변수로 업데이트를 해줬다...! zoomToFit()
, drop()
, convertToEmojiCoordinates()
, convertFromEmojiCoordinates()
, documentBody
의 OptionalImage
와 emoji.text
struct EmojiArtDocumentModel {
...
@State private var steadyStateZoomScale: CGFloat = 1
@GestureState private var gestureZoomScale: CGFloat = 1
private var zoomScale: CGFloat {
steadyStateZoomScale * gestureZoomScale
}
private func zoomGesture() -> some Gesture {
MagnificationGesture()
.updating($gestureZoomScale) { latestGestureScale, gestureZoomScale, _ in
gestureZoomScale = latestGestureScale
}
.onEnded { gestureScaleAtEnd in
steadyStateZoomScale *= gestureScaleAtEnd
}
}
...
}
zoom in/out
을 하고 나서 이모지를 추가하면 이상한 위치에 자꾸 추가됐다...뭔가 zoomScale
을 도출하는 부분이나 이를 사용해서 좌표를 구하는 부분에서 문제가 있나 싶어서 코드를 다 보고, demo code
랑도 비교를 해봤는데 문제가 없었다 결국 전체 코드를 다 뜯어보기 시작했는데... position()
다음에 scaleEffect()
를 적용하고 있었다...이모지부터 추가하고 그 상태에서 줌을 하니...당연히 이상하게 보였던 것...아래와 같이 바꿔줬더니 바로 멀쩡하게 원하는 위치에 이모지가 추가됐다...기쁜데...슬펐다... struct EmojiArtDocumentView {
...
ZStack {
Color.white
.overlay(
OptionalImage(uiImage: document.backgroundImage)
.scaleEffect(zoomScale)
.position(convertFromEmojiCoordinates((0, 0), in: geometry))
)
.gesture(doubleTapToZoom(in: geometry.size))
if document.backgroundImageFetchStatus == .fetching {
ProgressView()
.scaleEffect(2)
} else {
ForEach(document.emojis) { emoji in
Text(emoji.text)
.font(.system(size: fontSize(for: emoji)))
.scaleEffect(zoomScale) // 바로 여기...
.position(position(for: emoji, in: geometry))
}
}
}
....
}
panGesture()
구현의 경우 CGSize extension
을 몇 개 추가해야되는 것 빼고는 비교적 쉽게 구현할 수 있었다...해당 함수에서 offSet
의 정도를 zoomScale
로 나눠주는 것까지는 바로 이해가 갔는데(확대된 상태에서는 실제 손가락이 이동한 거리보다 이미지가 이동한 거리가 작을 것이므로)...panOffset 변수
에서 교수님 설명에 의하면 확대된 상태에서는 더 많이 이동하고 축소된 상태에서는 덜 이동해야 하므로 zoomScale
을 곱해줘야 한다는데 이게 이해가 안갔다...위의 로직을 생각해 보면 그 반대가 아닌가...? 그래서 내 생각대로 나누기 처리를 해 봤는데 이러니까 예상한 것보다 너무 적게 움직였다... struct EmojiArtDocumentView {
...
@State private var steadyStatePanOffset: CGSize = CGSize.zero
@GestureState private var gesturePanOffset: CGSize = CGSize.zero
private var panOffset: CGSize {
(steadyStatePanOffset + gesturePanOffset) * zoomScale
}
private func panGesture() -> some Gesture {
DragGesture()
.updating($gesturePanOffset) { latestDragGestureValue, gesturePanOffset, _ in
gesturePanOffset = latestDragGestureValue.translation / zoomScale
}
.onEnded { finalDragGestureValue in
steadyStatePanOffset = steadyStatePanOffset + (finalDragGestureValue.translation / zoomScale)
}
}
...
}
private func convertToEmojiCoordinates(_ location: CGPoint, in geometry: GeometryProxy) -> (Int, Int) { let center = geometry.frame(in: .local).center let location = ( x: (location.x - center.x - panOffset.width) / zoomScale, y: (location.y - center.y - panOffset.height) / zoomScale ) return (Int(location.x), Int(location.y)) }
private func convertFromEmojiCoordinates(_ location: (x: Int, y: Int), in geometry: GeometryProxy) -> CGPoint { let center = geometry.frame(in: .local).center return CGPoint( x: center.x + CGFloat(location.x) * zoomScale + panOffset.width, y: center.y + CGFloat(location.y) * zoomScale + panOffset.height ) }
panOffset 변수
가 사용되는 미치는 두 함수 convertFromEmojiCoordinates()
와 convertToEmojiCoordinates()
를 봤더니 함수 자체에서 이미 zoomScale
만큼 나눗셈과 곱셈을 하고 있어서 여기에 맞춰서 표현하기 위해 그렇게 하신 것 같다...약간 panOffset 변수
자체를 zoomScale
에 대한 상대값으로 생각하셔서 그런 것 같은데 나차럼 panOffset 변수
를 절댓값으로 생각했다면 아래와 같이 구성하는 게 더 이해가 쉬울 것 같다..!import SwiftUI
struct EmojiArtDocumentView: View {
...
private var panOffset: CGSize {
(steadyStatePanOffset + gesturePanOffset)
}
private func convertToEmojiCoordinates(_ location: CGPoint, in geometry: GeometryProxy) -> (Int, Int) {
let center = geometry.frame(in: .local).center
let location = (
x: (location.x - center.x) / zoomScale - panOffset.width,
y: (location.y - center.y) / zoomScale - panOffset.height
return (Int(location.x), Int(location.y))
}
private func convertFromEmojiCoordinates(_ location: (x: Int, y: Int), in geometry: GeometryProxy) -> CGPoint {
let center = geometry.frame(in: .local).center
return CGPoint(
x: center.x + (CGFloat(location.x) + panOffset.width) * zoomScale,
y: center.y + (CGFloat(location.y) + panOffset.height) * zoomScale
)
}
...
}
.gesture(panGesture().simultaneously(with: zoomGesture())
아마도 최초로 삼수강한 강의...욕심이 생겨서 포스팅 쓰면서 공부도 가장 많이 했고 암튼 정말 오래걸렸다...물론 빡집중했다면 더 빨리 끝낼 수 있었겠지만 아무튼 결국 완결냈다는 것에 의의를 두는 것으로...ㅋㅋㅋ
늘 느끼는 거지만 강의 들을 땐 참 쉽죠~? 인데 막상 내가 해보면 아니 여기서 어떻게 한거지?! 싶고 실수연발이라 혼자 뭐가 문제야 맞는데 왜 틀리대...하면서 시간을 엄청 날린다...어떻게 보면 당연한건데 그럴 때마다 조급해지는 게 문제...사실은 그냥 빨리 잘하고 싶은 내 마음이 문제같기도...ㅎㅎㅎ
private func fetchBackgroundImageDataIfNecessary() {
// why do I have to set this to nil...?
backgroundImage = nil
switch emojiArt.background {
case .url(let url):
// fetch the url
DispatchQueue.global(qos: .userInitiated).async {
let imageData = try? Data(contentsOf: url)
DispatchQueue.main.async {
if imageData != nil {
// u can never publish sth in a background thread!!
self.backgroundImage = UIImage(data: imageData!)
}
}
}
case .imageData(let data):
backgroundImage = UIImage(data: data)
case .blank:
break
}
@GestureState private var gestureZoomScale: CGFloat = 1