본문 바로가기
C.W.K.
Stream
Lesson 05 of 05 · published

캔버스는 언제나 펜 차지야

~15 min · apple-pencil, uikit, touch-handling, state-machines, hardware-testing

Level 0빈 캔버스
0 XP0/37 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

한 번에 터치 하나

Ariandel 캔버스는 한 번에 터치 하나에만 반응해. 뷰는 activeTouch 하나를 쥐고 있고, 문서엔 "the one touch acting on the canvas (a stroke, a selection, a drag, a tap)"라고 적혀 있어. touchesBegan은 그 자리가 비어 있을 때만 새 터치한테 캔버스를 내줘. 규칙 자체는 멀쩡해. 동시에 두 획이 돌면 Painter 하나를 두고 싸울 거고, 두 번째 손가락은 칠하는 게 아니라 화면 이동이 돼야 하니까. 그런데 이벤트로만 비워지는 자리엔 약점이 있어. 그 이벤트가 꼭 온다고 믿는다는 거.

죽어 버린 펜

2026-10-03, 아빠가 iPad에서 겪은 걸 알려 왔어. 가끔 손으로 화면을 만지고 나면 펜이 그리기를 멈추는데, 다른 도구를 몇 번 눌렀다가 브러시로 돌아오면 다시 된다는 거였어. 그날 12:05에 답한 커밋(de3bef7)이 원리를 짚어. 터치의 끝이 붙잡혔든 사라졌든 뷰까지 안 오면, activeTouch가 그대로 남아서 그 뒤 펜 획이 전부 거절당했어. 아빠가 손으로 찾아낸 탈출구도 진짜였어. 패널을 열면 창 안의 터치가 전부 취소되고, 취소가 자리를 비우고, 펜이 다시 돼. 그래서 도구들이 "고쳐 주는" 것처럼 보였던 거야.

이 버그한테 필요 없었던 걸 봐. 크래시도, 예외도, 스레드 경쟁도 아냐. 빠진 콜백 하나, 그리고 그걸 기다리는 상태 하나면 충분했어.

reclaimCanvas: 끝을 기다리지 마

수정은 질문을 뒤집어. 옛 터치가 작별 인사하길 기다리는 대신, 새 터치가 닿을 때마다 옛 터치가 정말 아직 있는지 확인해. reclaimCanvas(for:)(첫 번째 코드 블록)는 touchesBegan이 캔버스 상태에 손대는 첫 동작이고, 규칙은 셋이야.

  1. 쥐고 있는 터치의 단계가 이미 .ended나 .cancelled면, 끝이 뷰까지 안 왔을 뿐이야. 뗀 걸로 치고 마무리해.
  2. 앞선 펜 터치를 쥔 채로 펜이 또 닿으면, 앞의 건 끝난 게 틀림없어. 펜은 하나뿐이니까. 똑같이 마무리해.
  3. 손가락이 캔버스를 쥐고 있는데 펜이 닿으면, 펜이 가져가. 손가락이 하던 일은 버려. 획이었다면 건드린 타일을 전부 되돌린다는 뜻이야.

"뗀 걸로 마무리"는 진짜로 뗄 때 쓰는 finishActiveTouch를 이벤트 없이 그대로 거쳐. 쥐고 있던 터치를 지금 모습대로 한 번 더 먹이고 획을 평소처럼 끝내. 세 번째 코드 블록의 컴파일한 스케치가, 사라진 끝이 가질 수 있는 모양 세 가지로 수정 전후의 자리를 흉내 내. 규칙이 없으면 펜 획 세 번이 모든 경우에 거절당해. 있으면 셋 다 그려져. Drawing Pad도 첫 번째 규칙을 받았어. 새 터치가 단계가 끝난 옛 터치를 마무리해. 하우스 룰은 교훈을 한 줄로 남겨. "The canvas never waits on a touch's end to let the Pencil in."

펜이 가진 나머지 전부

  • 더블탭과 스퀴즈. 캔버스에 붙은 UIPencilInteraction이 둘 다 넘겨줘. 각각 따로 여덟 동작 중 하나로 정해. As the iPad Says(시스템이 고른 탭이나 스퀴즈 동작), Brush and Eraser, Previous Tool, Colors, QuickMenu, Eyedropper, Undo, Nothing. 스퀴즈는 손을 풀 때 동작하고, 스포이트와 QuickMenu는 펜이 떠 있는 자리에서 동작해.
  • 호버. UIHoverGestureRecognizer가 떠 있는 펜을 따라가. 그리기 모드에선 브러시 커서가 켜져 있으면 붓 윤곽을 그리고, Liquify는 늘 크기를 보여 줘. 호버 제스처가 켜져 있으면(기본값이야) 펜이 떠 있는 동안 두 손가락 핀치가 붓 크기를 바꾸고 한 손가락 밀기가 불투명도를 바꿔. 그동안 이동과 회전은 거절돼서 캔버스가 가만히 있어.
  • Palm Support. 기본은 꺼져 있어. Standard는 majorRadius가 42포인트 이상인 터치를 기댄 손으로 보고, Fine은 그걸 30으로 낮춰. 대신 가끔 넓은 손가락 끝도 걸려(두 번째 코드 블록). 한 번 손바닥으로 판정된 터치는 뗄 때까지 손바닥이야. 아무 동작도 안 하고, 화면 이동 제스처에도 안 닿고, 손가락 수에도 안 들어가. 획을 긋던 손가락이 손바닥처럼 넓어지면 그 획은 버려져.

테스트가 말 못 하는 것

이 강의의 동작은 전부 진짜 Apple Pencil에서 시작해. 그런데 테스트 관문은 iPad 시뮬레이터에서 돌고, 시뮬레이터엔 펜이 없어. de3bef7 커밋이 마지막 줄에서 그렇게 말해. "The lost end itself was not reproduced; the simulator has no Pencil. Dad's iPad is the witness." 그 라운드들의 coop 기록 행도 같은 정직함을 지켜. 스퀴즈와 더블탭은 제스처를 "tested through the model"로만 확인했어. 펜 호버도, 그래서 호버 제스처도 모델로만 확인했고. 진짜 손바닥, 그리고 아빠 iPad에서의 사라진 터치 끝은 아예 못 해 봤어. 테스트가 증명하는 범위는 생각보다 좁아. 펜 제스처 동작마다, 그리고 손바닥 문턱값엔 단위 테스트가 있고, 커밋 당시 있던 칠하기·선택·변형·채우기·테이퍼 UI 테스트도 통과했어. 터치 끝을 일부러 빠뜨리는 테스트는 없어. 위 스케치가 그나마 제일 가까워. iPad가 또 끝을 잃을지, 42포인트가 진짜 손에 맞는지는 하드웨어 사실이야. 8트랙은 그 경계를 습관으로 만들어.

보장 못 하는 이벤트를 상태가 기다리게 두지 마. 콜백 하나로만 비워지는 자리라면, 다음에 오는 쪽이 그 자리 주인이 정말 아직 있는지 확인할 방법을 줘. 끝이 사라지는 일은 드물어. 그래도 빠져나갈 길 없는 드문 실패는 고장 난 앱으로 읽혀.

Code

Canvas/CanvasMetalView.swift 원문 그대로(문서 주석 생략): touchesBegan이 맨 먼저 부르는 reclaimCanvas·swift
    private func reclaimCanvas(for touches: Set<UITouch>) {
        guard let active = activeTouch, !touches.contains(active) else { return }
        let pencilLands = touches.contains { $0.type == .pencil }
        if active.phase == .ended || active.phase == .cancelled || (pencilLands && active.type == .pencil) {
            finishActiveTouch(event: nil)
        } else if pencilLands {
            abandonActiveTouch()
        }
    }
App/Studio.swift 원문(일부 생략: title switch 뺌): Palm Support 문턱값·swift
    /// Procreate's Palm Support Mode: how broad a touch must be to be taken
    /// for a resting hand and ignored.
    enum PalmSupport: String, Codable, CaseIterable, Identifiable {
        case off
        /// Only plainly broad touches: a palm laid flat.
        case standard
        /// Narrower ones too: the edge of the hand, at the cost of a broad fingertip now and then.
        case fine

        /// The touch radius, in points, from which a touch is a palm.
        var threshold: CGFloat? {
            switch self {
            case .off: nil
            case .standard: 42
            case .fine: 30
            }
        }

        func isPalm(majorRadius: CGFloat) -> Bool {
            threshold.map { majorRadius >= $0 } ?? false
        }
    }
최소 스케치(swiftc로 컴파일해서 돌림): reclaimCanvas 전후의 터치 자리 하나, 사라진 끝의 세 가지 모양으로·swift
// A minimal sketch of the canvas's one-touch rule, before and after
// reclaimCanvas: the canvas holds a touch whose touchesEnded never arrives.
enum Kind { case pencil, finger }
enum Phase { case began, moved, ended, cancelled }
final class Touch {
    let kind: Kind
    var phase = Phase.began
    init(_ kind: Kind) { self.kind = kind }
}

final class Canvas {
    var active: Touch?
    let reclaims: Bool
    init(reclaims: Bool) { self.reclaims = reclaims }

    /// touchesBegan: returns whether this touch got the canvas.
    func lands(_ touch: Touch) -> Bool {
        if reclaims, let held = active, held !== touch {
            let pencilLands = touch.kind == .pencil
            if held.phase == .ended || held.phase == .cancelled || (pencilLands && held.kind == .pencil) {
                active = nil                       // finished as if lifted
            } else if pencilLands {
                active = nil                       // a finger's work is abandoned
            }
        }
        guard active == nil else { return false }
        active = touch
        return true
    }

    /// touchesEnded, when UIKit delivers it.
    func lifts(_ touch: Touch) { if active === touch { active = nil } }
}

// Which touch lost its end was never reproduced, so try three shapes of it.
let stuck: [(String, Kind, Phase)] = [
    ("finger, ended", .finger, .ended),       // UIKit marked it ended; the view never heard
    ("finger, still down", .finger, .moved),  // a finger really resting on the glass
    ("pencil, end lost", .pencil, .moved),    // an earlier Pencil touch never finished
]
for reclaims in [false, true] {
    for (name, kind, phase) in stuck {
        let canvas = Canvas(reclaims: reclaims)
        let held = Touch(kind)
        _ = canvas.lands(held)
        held.phase = phase
        var strokes: [Bool] = []
        for _ in 0..<3 {                           // three Pencil strokes
            let pencil = Touch(.pencil)
            strokes.append(canvas.lands(pencil))
            pencil.phase = .ended
            canvas.lifts(pencil)
        }
        print(reclaims ? "with reclaim, " : "no reclaim,   ", name + String(repeating: " ", count: 18 - name.count), strokes)
    }
}

// Prints:
// no reclaim,    finger, ended      [false, false, false]
// no reclaim,    finger, still down [false, false, false]
// no reclaim,    pencil, end lost   [false, false, false]
// with reclaim,  finger, ended      [true, true, true]
// with reclaim,  finger, still down [true, true, true]
// with reclaim,  pencil, end lost   [true, true, true]

External links

Exercise

네 프로젝트에서 이벤트 하나로 켜지고 다른 이벤트 하나로만 꺼지는 상태를 찾아. 완료 때 풀리는 바쁨 플래그, 콜백에서 풀리는 잠금, 마우스를 뗄 때 끝나는 드래그, 닫을 때 사라지는 모달 같은 것. 끄는 이벤트가 영영 안 오면 무슨 일이 생기는지 적어. 그다음 다음 도착 때 되찾기 확인을 넣어. 이미 가진 정보만으로 옛 주인이 정말 아직 있는지 가려낼 수 있어야 해. 끄는 이벤트를 일부러 빠뜨리는 테스트도 써.
Hint
되찾기 확인이 사라진 이벤트에 기대면 안 돼. 시스템이 어차피 들고 있는 증거를 찾아. 단계나 상태 필드, 비교할 수 있는 타임스탬프, 새로 온 쪽의 정체(펜은 하나니까 앞선 펜 터치는 끝났을 수밖에 없어). 그다음 되찾을 때 옛 주인의 작업을 어떻게 할지 정해. 정상적으로 끝난 걸로 마무리할지, 버릴지.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인 — 댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.