개발을 시작하는 이야기

[SwiftUI]Lecture 10: Multithreading Demo Gestures 본문

개발 이야기/Swift

[SwiftUI]Lecture 10: Multithreading Demo Gestures

Teiresias 2022. 4. 29. 20:07

강의 보기 : YouTube :: Stanford

URL과 Image를 Drag & Drop

이모지 외에도 배경을 Drag & Drop 할 수 있기 때문에 URL이나 Image가 드롭되었을 때 이를 받을 수 있어야 한다.

private func drop(providers: [NSItemProvider], at location: CGPoint, in geometry: GeometryProxy) -> Bool {
    var found = providers.loadObjects(ofType: URL.self) { url in
        document.setBackground(EmojiArtModel.Background.url(url))
    }
    if !found {
        found = providers.loadObjects(ofType: UIImage.self) { image in
            if let data = image.jpegData(compressionQuality: 1.0) {
                document.setBackground(.imageData(data))
            }
        }
    }
    if !found {
        found = providers.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
}

이를 위해서 기존 drop 함수에 found 변수를 새로 선언해서 provider가 이미지, URL, 이모지 중 무엇을 담고 있는지 순차적으로 확인하게 된다. 이미지의 경우 View에서는 Image의 형태로, ViewModel에서는 UIImage의 형태로, Model에서는 URL의 형태로 보관하게 되는데, 각각 자신이 보관하기 좋은 형태로 저장해 두고 ViewModel이 중간에서 원할이 전달될 수 있도록 적절한 방식으로 가공된 데이터를 전달하게 된다.

 

불필요한 URL 정리하기

드랍한 이미지의 URL에서 불필요한 정보를 제외한 이미지 URL만 추출하는 과정이 필요하다. 

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
                }
            }
        }
        return baseURL ?? self
    }
}

 

일단 입력된 URL 주소를 '&' 로 분류하게 되는데 이때 '&'는 값이 있을 수도, 없을 수도 있기 때문에 Optional 분류로 작성해준다. 그리고 난 값을 '='로 분류한 값들을 저장한 후 첫 번째 요소가 "imgURL"이라면 % 를 제거해서 imgURL을 추출하는 것 같다.

이미지 jpeg 형식으로 변환하기

이미지를 jpeg 형식으로 변환하는 것은 내장함수를 사용하여 손쉽게 처리가 가능하다.

found = providers.loadObjects(ofType: UIImage.self) { image in
    if let data = image.jpegData(compressionQuality: 1.0) {
        document.setBackground(.imageData(data))
    }
}

ViewModel에서 UIImage 생성하기

이미지를 드랍하게 되면 Model에는 해당 이미지의 URL이나 ImageData가 저장되고, ViewModel에서는 UIImage형식으로 저장하기 때문에 ViewModel은 Model에서 받은 데이터를 UIImage로 변환한다.

사용자가 배경화면을 변경할 때마다 View에 알릴 필요가 있기 때문에 @Published로 선언해준다. 하지만 이때 이미지를 불러오지 못하는 오류가 발생할 수 있기 때문에 backgroundImage: UIImage?와 같이 Optional 처리를 해준다. 또한 이미지를 불러오는데 시간이 소요될 수 있기 때문에 computed var로 선언할 수 없고, 매번 set 되어야 하기 때문에 property observer인 didset을 통해 Model에서 배경화면이 변경될 때마다 업데이트되도록 한다.

class EmojiArtDocument: ObservableObject {
    @Published private(set) var emojiArt: EmojiArtModel {
        didSet {
            if emojiArt.background != oldValue.background {
                fetchBackgroundImageDataIfNecessary()
            }
        }
    }
    
    ...
    
    var background: EmojiArtModel.Background { emojiArt.background }
    
    @Published var backgroundImage: UIImage?
    
    ...
    
    private func fetchBackgroundImageDataIfNecessary() {
    ...
    }
}

Global, Main Queue

URL에서 이미지를 가져오는 것은 파일의 용량에 따라 오래걸릴 수 있기 때문에 Global Queue에서 작업을 수행한다. 하지만 불러온 이미지를 배경화면에 보여주는 것은 UI에 영향을 미치기 때문에 Main Queue에서 수행해준다.

 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 { [weak self] in
                if self?.emojiArt.background == EmojiArtModel.Background.url(url) {
                    self?.backgroundImageFetchStatus = .idle
                    if imageData != nil {
                        self?.backgroundImage = UIImage(data: imageData!)
                    }
                }
            }
        }
    case .imageData(let data):
        backgroundImage = UIImage(data: data)
    case .blank:
        break
    }
}

Main Queue에서 backgroundImage를 넣어주면 다음과 같은 애러가 발생한다. Reference to property 'backgroundImage' in closure requires explicit use of 'self' to make capture semantics explicit closure와 이를 담고 있는 ViewModel이 모두 Reference Type이기 때문에 ARC의 Strong Reference Cycle을 방지하기 위해 self를 붙이라는 뜻이다. backgroundImage 앞에 self. 를 붙여주면 해결할 수 있지만 Strong Reference Cycle의 문제는 해결되지 않는다. Reference Type의 경우 어떤 함수, 클로저, 변수, 상수 중 하나라도 reference type을 담고 있다면 계속해서 메모리상에 저장되기 때문이다. 

 

Main Queue는 인자로 closure를 받는데 이는 reference type이므로 작업이 끝날때까지 메모리에 저장된다. 마찬가지로 ViewModel 역시 reference type이기 때문에 메모리에 저장 되게 된다. 이때 self.로 인해 Main Queue의 closure가 ViewModel을 가리키게 되어 Strong reference가 생기게 되고, 파일을 닫더라도 메모리에 ViewModel 이 남아있게 된다. 즉, Strong Reference Cycle이 발생하게 되는 것이다.

 

그래서 클로저와 클래스 인스턴스간의 Strong Reference Cycle은 클로저 정의 시 클로저가 reference type을 내부에 캡처할 때 따르는 규칙을 담고 있는 capture list를 함께 정의함으로써 해결해주었다. 그리고 reference가 필요에 따라 weak 혹은 unowned가 되도록 정의해주면 된다. weak를 사용하면 optional이 되어 다른 누구도 힙에 해당 변수를 보관하고 있지 않다면 힙에 저장되지 않고 nil로 반환한다. 

  • weak: 인스턴스가 먼저 프리되어 nil이 될 때 사용
  • unowned: 클로저와 인스턴스가 항상 서로를 가리키고, 동시에 프리 될 때 사용

중복 변경 방지

계속해서 고려하는 로딩이 오래 걸리는 경우에 대한 대비중 하나로, 로딩 중에 사용자가 또 다른 배경을 드롭하는 일이 생길 수 있다. 이때 이미 지나가버린 이전의 배경이 뒤늦게 로딩되는 것을 방지하기 위해 로드되는 이미지가 사용자가 요청한 이미지와 동일한지 확인 하고 이미지를 보여주는 과정이 필요하다. if self?.emojiArt.background == EmojiArtModel.Background.url(url)을 통해 Model의 이미지가 현재 불러온 URL과 동일한지 확인 과정을 거친다.


Gesture 인식

모든 SwiftUI View 에는 Gesture 인식기가 연결될 수 있으며 이러한 Gesture 인식기는 인식기가 활성화될 때 실행될 클로저를 차례로 연결할 수 있다.

TabGesture

이것을 만들 때 Gesture를 트리거하는데 걸리는 텝 수를 지정한 다음, 제스처가 발생할 때 실행될 onEnded 클로저를 연결할 수 있다. 

private func doubleTabToZoom(in size: CGSize) -> some Gesture {
    TapGesture(count: 2)
        .onEnded {
            withAnimation{
                zoomToFit(document.backgroundImage, in: size)
            }
        }
}

Discrete Gesture

제스처가 시작과 동시에 끝이 나며 하나의 반응만을 필요로 한다. 

.onEnded { }를 사용한다.

Non-Discrete Gesture

제스처가 발생하는 동안 여기에 대해 반응해야 한다. DragGesture, MagnificationGesture, RotationGesture, LongPressGesture

.onEnded { }를 사용하는 것은 동일하나 value 인자가 발생한다. value는 해당 제스처가 끝났을 때의 상태를 알려주며 종류에 따라 다르다. value에 따라 @GestureState를 이용해서 제스처의 상태를 계속 업데이트 함으로써 제스처 시행 도중에도 반응을 할 수 있다. 단, 제스처가 종료된 후에는 <starting value>로 돌아간다.

 

@GestureState var myGestureState: MyGestureStateType = <starting value>

private func panGesture() -> some Gesture {
    DragGesture()
        .updating($gesturePanOffset) { lastestDragGestureValue, gesturePanOffset, _ in
            gesturePanOffset = lastestDragGestureValue.translation / zoomScale
        }
        .onEnded { finalDragGestureValue in
            steadyStatePanOffset = steadyStatePanOffset + (finalDragGestureValue.translation / zoomScale)
        }
}

.updating 안에 @GestureState 프로퍼티 래퍼가 붙은 변수 앞에 $ 표시를 붙여서 사용한다.

 

.onChanged 라는 단순한 버전이 있다. 추적이 필요하지 않고, 제스처의 결과만을 나타낼 때 유용하다. 그러나 상대적인 위치에 반응하는 경우에는 .updating이 적절하다.

 

DoubleTab

Gesture를 리턴하는 zoomGesture()와 doubleTabToZoom() 함수를 만들어주고, 호출되었을 때 어떤 동작을 하게 할지 지정해준다. zoomToFit() 함수에서 도출한 zoom 상태의 길이와 너비를 배경 이미지에 적용하기 위해 @State var Scale: CGFloat을 선언하고 배경 이미지에 .scaleEffect(zoomScale)로 적용한 후 계속 업데이트해준다.

 

var documentBody: some View {
    GeometryReader { geometry in
        ZStack {
            Color.white.overlay(
                OptionalImage(uiImage: document.backgroundImage)
                    .scaleEffect(zoomScale)
                    .position(convertFromEmojiCoordinates((0,0), in: geometry))
            )
                .gesture(doubleTabToZoom(in: geometry.size))
            ...
        }
    }
}

...

@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, gestureStateInOut, transition in
            gestureStateInOut = latestGestureScale
        }
        .onEnded { gestureScaleAtEnd in
            steadyStateZoomScale *= gestureScaleAtEnd
        }
}

private func doubleTabToZoom(in size: CGSize) -> some Gesture {
    TapGesture(count: 2)
        .onEnded {
            withAnimation{
                zoomToFit(document.backgroundImage, in: size)
            }
        }
}

private func zoomToFit(_ image: UIImage?, in size: CGSize) {
    if let image = image, image.size.width > 0, image.size.height > 0, size.width > 0, size.height > 0 {
        let hZoom = size.width / image.size.width
        let vZoom = size.height / image.size.height
        steadyStatePanOffset = .zero
        steadyStateZoomScale = min(hZoom, vZoom)
    }
}

Clipped()

View는 디폴트로 자신에게 주어진 영역 밖을 침범할 수 있기 때문에 Clipped()를 선언해준다.

var documentBody: some View {
    GeometryReader { geometry in
        ZStack {
            ...
        }
        .clipped()
        
        ...
    }
}

Pan Gesture

Tap Gesture와는 다르게 zoom이 in-out 되는 동안 계속 이미지의 크기가 업데이트되어야 한다. 이를 위해 .updating을 사용해서 가장 최근의 값을 추적해서 제스처 중에는 gestureZoomScale을, 제스처가 끝난 경우엔 steadyStateZoomScale을 업데이트해준다.

 

그리고 코드 수정을 줄이기 위해 zoomScale을 연산 변수로 지정해주었다.

private var zoomScale: CGFloat {
    steadyStateZoomScale * gestureZoomScale
}

제스처 중에는 gestureZoomScale이 반영되고, 제스처가 끝나면 gestureZoomScale은 초기값인 1로 돌아가 영향력이 없어지기 때문에 steadyStateZoomScale과 동일해진다.

 

panGesture()의 경우 비교적 쉽게 구현할 수 있었다. 단지 확대된 상태에서는 더 많이 이동해야 하고, 축소된 상태에서는 덜 이동해야 하기 때문에 zoomScale을 곱해주어야 한다고 하는데, 처음에는 쉽게 이해가 가지 않았는데 덧셈과 나눗셈으로 테스트해보고 알게 되었다.

@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) { lastestDragGestureValue, gesturePanOffset, _ in
            gesturePanOffset = lastestDragGestureValue.translation / zoomScale
        }
        .onEnded { finalDragGestureValue in
            steadyStatePanOffset = steadyStatePanOffset + (finalDragGestureValue.translation / zoomScale)
        }
}

simultaneously(with:)

두 개의 제스처를 동시에 적용해주어야 할때, 두개의 제스처를 각각 지정해주면 의미가 명확하지 않기 때문에 지양해야 한다. simultaneously(width: )를 사용해서 서로 다른 제스처를 결합하여 두 제스처를 동시에 인식하는 제스처를 작성한다.

var documentBody: some View {
        GeometryReader { geometry in
            ZStack {
            	...
            }
            .clipped()
            .onDrop(of: [.plainText, .url, .image], isTargeted: nil) { providers, location in
                drop(providers: providers, at: location, in: geometry)
            }
            .gesture(panGesture().simultaneously(with: zoomGesture()))
        }
    }

이제는 정말 강의를 한번에 듣고 이해하기 힘들어서 점점  오래걸린다. 이번 강의도 두번 돌려 듣고 부분적으로 세번까지 봐야 했다. 영어 강의라 집중이 잘 안되는데 내용마저 점점 복잡해진다....

 

 

참고 자료 : onEnded(_:)simultaneously(with: )