Lecture 10: Multithreading Demo Gestures
Model 에서 location은 센터를 기준으로 함 즉, (0, 0)은 정 가운데를 의미..!
- 이렇게 하면 어떤 기기에서든 센터를 기준으로 정렬되어 호환성이 좋고,
- 배경 이미지 또한 센터를 기준으로 위치를 정하면 돼서 편리함...!
- 사실 9강에서 썼어야되는 내용인데 그냥 여기에 낑겨 넣는다..
# 또 드랍...근데 이제 이미지와 url을 곁들인
-
이모지 아트에는 이모지 외에도 배경을 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인 경우
- 드래그한 이미지의 url이 출처 주소로 래핑되어있는 등 불필요한 정보가 포함되어있을 수 있으므로
이미지 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로부터 배경화면 UIImage 생성하기
-
배경화면에 이미지를 드래그 & 드랍하면
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...
}
...
}
UI에 영향을 주는 모든 것은 Main Queue 에서...!
- 위에서 얘기했듯이
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
}
}
...
# .self...아니..나는 약하다...weak self
- 위에서처럼
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!)
}
}
}
}
....
}
# View가 제스처를 인식하게 하기
.gesture
view modifier
사용
myView.gesture(theGesture)
theGesture
는Gesture 프로토콜
을 따르며 함수 / 연산 프로퍼티 /View
의body var
내부의 지역변수 로 생성됨
var theGesture: some Gesture { return TapGesture(count: 2) }
# 인식한 제스처에 반응하기
discrete gesture
- 한 번에 일어남, 제스처가 시작과 동시에 끝나며 하나의 반응만을 필요로 함
.onEnded { }
사용
var theGesture: some Gesture {
return TapGesture(count: 2)
.onEnded { // do sth }
}
convenience versions
가 존재
myView.onTapGesture(count: Int) { /* do sth */ }
non-discrete gesture
- 제스처가 발생하는 동안 여기에 대해 반응해야 함
- e.g.
DragGesture
,Magnification Gesture
,RotationGesture
,LongPressGesture(i.e. fingers down and fingers up
- e.g.
.onEnded
를 사용하는 것은 같으나 아래 슬라이드와 같이value
인자가 생김...!value
는 해당 제스처가 끝났을 때의 상태를 알려주며 제스처 종류에 따라 상이- e.g.
DragGesture
: 손가락의 시작 지점과 끝 지점 등을 담고 있는 구조체
- e.g.
- 핵심은
value
에 따라@GestureState
를 이용해서 제스처의 상태를 계속 업데이트함으로써 제스처 시행 도중에도 반응을 할 수 있음...! 단 제스처가 종료된 후에는<starting value>
로 돌아감
@GestureState var myGestureState: MyGestureStateType = <starting value>
.updating
안에@GestureState
프로퍼티 래퍼가 붙은 변수 앞에$
표시를 붙여서 사용...!
.onChanged
라는 단순한 버전이 있긴 한데 얘는 과정을 나타내지는 못하고, 제스처의 결과로 나타낼 반응이 손가락의 절대적인 위치와 관련이 있을 때(e.g. 유저가 손가락을 펜처럼 사용하는 경우) 유용하다. 그러나 상대적인 위치에 반응하는 경우(e.g. 드래그한 정도)에는.updating()
이 적절하다...! 아래 슬라이드 참고..!
# 더블 클릭하면 화면 맞춤...! : discrete gesture
- 더블 클릭했을 때 배경화면이 화면 크기에 딱 맞게 바뀌도록 하는 게 목표...!
-
먼저
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)
}
}
}
# Clipped
View
는 디폴트로 자신에게 주어진 영역 밖을 침범할 수 있으므로.clipped() modifier
를 이용하면 해결된다...!
# Zoom Zoom Zoom...! : non-discrete gesture
- 위의 더블 클릭 제스처와 달리 한번에 딱 끝나는 게 아니라 이번에는 두 손가락으로
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
}
}
...
}
View Modifier ORDERS MATTER!!!!!!!!!!
- 문제는...
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())
- 하나의 뷰에 두 개 이상의 제스처를 붙이면 의미가 명확하지 않으므로 지양해야 함
☀️ 느낀점
-
아마도 최초로 삼수강한 강의...욕심이 생겨서 포스팅 쓰면서 공부도 가장 많이 했고 암튼 정말 오래걸렸다...물론 빡집중했다면 더 빨리 끝낼 수 있었겠지만 아무튼 결국 완결냈다는 것에 의의를 두는 것으로...ㅋㅋㅋ
-
늘 느끼는 거지만 강의 들을 땐 참 쉽죠~? 인데 막상 내가 해보면 아니 여기서 어떻게 한거지?! 싶고 실수연발이라 혼자 뭐가 문제야 맞는데 왜 틀리대...하면서 시간을 엄청 날린다...어떻게 보면 당연한건데 그럴 때마다 조급해지는 게 문제...사실은 그냥 빨리 잘하고 싶은 내 마음이 문제같기도...ㅎㅎㅎ
how to load images and url
- in the View
- inside the drop function! switch using the found var...
actually fetching the background in the viewModel
- backgroundImage
background queue can't be published u know!
- 응 안 기다려~ : background queue
- 크큭...약한 놈... : weak instead of .self
- weak turn .self into an optional(turns nil if no one keeps it in the heap)
- 뒷북금지ㅋㅎ
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
}
#### continued...getting rid of .self - weak self!
var theGuesture: some Gesture {}
- all about changing ur @GestureState var based on the value repeatedly
- need to think about what state i need to know about in order to draw my View
discrete and indiscrete gestures
- have the lecture slide images...
Pinch (non-discrete)
- need to update zoom while the pinch is actually happening...!
- what state is changing while we're pinching?
- in this case, it's the zoomScale modified by how much we've pinched by so far
- scale of the fingers only while the pinch is happening : so kinda read-only
@GestureState private var gestureZoomScale: CGFloat = 1
- inout
Author And Source
이 문제에 관하여(Lecture 10: Multithreading Demo Gestures), 우리는 이곳에서 더 많은 자료를 발견하고 링크를 클릭하여 보았다 https://velog.io/@sunnysideup/Lecture-10-Multithreading-Demo-Gestures저자 귀속: 원작자 정보가 원작자 URL에 포함되어 있으며 저작권은 원작자 소유입니다.
우수한 개발자 콘텐츠 발견에 전념 (Collection and Share based on the CC Protocol.)