iOS 카메라 개발기 - 손을 떼기도 전에 끝나는 DragGesture
body가 초당 수십 번 재평가되는 화면에서 DragGesture가 0.1ms 만에 끝났다. UIKit 인식기로 내려가서야 해결됐고, 내려간 김에 스크롤과의 중재까지 떠안았다.
egg3분 읽기
카메라 화면은 SwiftUI 입장에서 좀 특이한 화면이다. 노출, 초점, 줌 같은 카메라 상태가 계속 바뀌니까 body가 초당 수십 번 재평가된다.
지난 편의 렌더링 문제도 여기서 나왔는데 제스처는 더 이상하게 망가졌다. 기기는 iPhone 17 Pro, iOS 26.6.1.
손을 떼기도 전에 onEnded
누른 채로 끄는 컨트롤에 DragGesture를 붙였더니 손을 떼기도 전에 onEnded가 불렸다. onChanged에서 onEnded까지 0.1~3ms. 사람 손가락으로 낼 수 있는 시간이 아니다.
@State로 옮겨보고, 참조 타입으로 바꿔보고, 경합할 만한 다른 제스처를 다 떼어봤다. 전부 실패였다.
아무래도 body 재평가가 제스처를 새로 만들면서 진행 중이던 걸 끝내버리는 것 같았다. 그래서 제스처를 body 밖으로 뺐다. UIViewRepresentable에서 UIKit 인식기를 makeUIView에서 딱 한 번 만든다.
struct PressTracker: UIViewRepresentable {
var onChange: (CGPoint) -> Void
var onEnd: () -> Void
func makeUIView(context: Context) -> UIView {
let v = UIView()
let g = UILongPressGestureRecognizer(target: context.coordinator,
action: #selector(Coordinator.handle))
g.minimumPressDuration = 0
v.addGestureRecognizer(g)
return v
}
// 재평가 때마다 새 클로저만 갈아 끼운다. 인식기는 그대로다
func updateUIView(_ uiView: UIView, context: Context) {
context.coordinator.parent = self
}
func makeCoordinator() -> Coordinator { Coordinator(self) }
final class Coordinator: NSObject {
var parent: PressTracker
init(_ parent: PressTracker) { self.parent = parent }
@objc func handle(_ g: UILongPressGestureRecognizer) {
switch g.state {
case .began, .changed: parent.onChange(g.location(in: g.view))
case .ended, .cancelled, .failed: parent.onEnd()
default: break
}
}
}
}
팬 대신 minimumPressDuration: 0짜리 long press를 쓴 건, UIPanGestureRecognizer에는 최소 이동 거리가 있어서 제자리에서 누르는 걸 못 잡기 때문이다.
ScrollView 안에 DragGesture를 붙였더니
다른 화면에는 세로 스크롤 목록 안에 드래그로 옮기는 요소가 있었다. DragGesture를 붙이는 순간 스크롤이 죽었다. 콜백이 비어 있어도 죽고 simultaneousGesture로 바꿔도 소용없었다.
결국 여기도 UIKit으로 내려갔다. 인식기를 스크롤 뷰에 직접 달고 gestureRecognizerShouldBegin에서 드래그 방향을 보고 누가 가져갈지 정한다.
그런데 인식기를 서브클래스로 만들 때 순서에서 한 번 막혔다. 방향 판정을 super.touchesMoved보다 먼저 해야 한다. super가 먼저 돌면 인식기가 이미 .began이 되어서 .failed로 보낼 수가 없다. 그러면 require(toFail:)로 이 인식기에 묶인 스크롤 팬이 영영 시작하지 못한다.
아래는 세로 스크롤 안의 가로 드래그 예시다.
import UIKit.UIGestureRecognizerSubclass
final class HorizontalPan: UIPanGestureRecognizer {
private var start: CGPoint = .zero
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
start = touches.first?.location(in: view) ?? .zero
super.touchesBegan(touches, with: event)
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent) {
// super보다 먼저. super가 돌고 나면 이미 .began이다
if state == .possible, let p = touches.first?.location(in: view),
abs(p.y - start.y) > abs(p.x - start.x) {
state = .failed
return
}
super.touchesMoved(touches, with: event)
}
}
고치고 나니 이번엔 시트를 아래로 끌어 닫는 시스템 제스처가 죽었다. 새 인식기가 끼어들면서 생긴 일 같다. shouldRecognizeSimultaneouslyWith에서 true를 돌려주니 살아났다.
자잘한 것 둘도 적어둔다.
location(in: scrollView)는 화면 좌표가 아니라 콘텐츠 좌표다. 스크롤 뷰의 bounds.origin이 contentOffset이기 때문이다. 스크롤한 만큼 값이 밀려 있다.
가장자리에 끌고 가면 자동으로 스크롤되게 했는데, 속도를 프레임당 pt로 잡으면 주사율에 따라 달라진다. 120Hz에서 프레임당 14pt면 초당 1680pt다. 초당 pt에 프레임 간격을 곱해야 한다.
어디를 눌러도 초점이 왼쪽 위에
탭한 곳에 초점을 맞추게 했는데 어디를 눌러도 왼쪽 위에 잡혔다. 인식기가 이미 0~1로 나눈 좌표를 넘기고 있었고 받는 쪽이 그걸 또 화면 크기로 나누고 있었다.
그래서 정규화는 한 곳으로 몰았다. 어디서 하는지 타입이나 이름에 드러나 있었으면 두 번 나눌 일도 없었을 것 같다.
빠른 드래그는 스냅 지점을 건너뛴다
줌 다이얼이 1×, 2× 같은 정수 배율을 지날 때 햅틱을 울리게 했다. 판정은 “현재 위치가 스냅 지점 ±8pt 안인가”였다.
천천히 돌리면 울리고 빠르게 돌리면 안 울렸다. 감도 배율이 곱해져서 한 프레임에 17pt씩 움직이고 있었다. ±8pt, 폭 16pt짜리 구간을 한 프레임에 통째로 건너뛴 것이다.
그래서 판정을 직전 값과 현재 값 사이에 지점이 끼어 있는지로 바꿨다.
let lo = min(previous, current), hi = max(previous, current)
if snapPoints.contains(where: { lo < $0 && $0 <= hi }) {
haptic.impactOccurred()
}
SwiftUI 제스처로 끝까지 버텨보고 싶었는데 결국 UIKit이 두 번 이겼다. 초당 수십 번 다시 그려지는 화면이라면 처음부터 UIKit 인식기로 가는 게 시간을 아낄 것 같다.
다음 편은 이 다이얼을 기본 카메라와 똑같이 만들려고 스크린샷을 픽셀 단위로 재던 이야기다.
Filed under 개발