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가 제스처를 인식하게 하기
.gestureview 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.)