Skip to content
C.W.K.
Stream
Lesson 05 of 05 · published

The Pencil Always Gets the Canvas

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

Level 0Blank Canvas
0 XP0/37 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

One Touch at a Time

Ariandel's canvas acts on one touch at a time. The view keeps a single activeTouch, documented as "the one touch acting on the canvas (a stroke, a selection, a drag, a tap)", and touchesBegan only gives the canvas to a new touch while that slot is empty. The rule is sound. Two simultaneous strokes would fight over one Painter, and a second finger is supposed to turn into navigation, not paint. But a slot that is emptied only by an event has a weak spot: it trusts that the event arrives.

The Dead Pencil

On 2026-10-03 Dad reported it from his iPad: "sometimes, after touching with his hand, the Pencil stops drawing; tapping other tools a few times and coming back to the brush brings it back." The commit that answered him (de3bef7, 12:05 the same day) names the mechanism. If a touch's end never reached the view, held back or lost, activeTouch stayed set and every Pencil stroke after it was refused. The way out Dad had found by hand was real too: opening a panel cancels every touch in the window, the cancel clears the slot, and the Pencil works again. That is why it looked like the tools "fixed" it.

Notice what the bug did not need: a crash, an exception, a race between threads. It needed one missing callback and one piece of state that waited for it.

reclaimCanvas: Stop Waiting for the End

The fix turns the question around. Instead of waiting for the old touch to say goodbye, every new landing checks whether the old touch is really still there. reclaimCanvas(for:) (first code block) is the first thing touchesBegan does to the canvas's state, and it has three rules:

  1. If the held touch's phase is already .ended or .cancelled, its end simply never reached the view. Finish it as if it had lifted.
  2. If a Pencil lands while an earlier Pencil touch is held, the earlier one must be over, because there is only one Pencil. Finish it the same way.
  3. If a Pencil lands while a finger holds the canvas, the Pencil takes it. The finger's work is abandoned, and for a stroke that means every tile it touched is put back.

"Finish as lifted" goes through the same finishActiveTouch a real lift uses, with no event: the held touch is fed once more as it stands and the stroke ends normally. The compiled sketch in the third code block models the slot before and after, for three shapes the lost end could have taken. Without the rule, three Pencil strokes are refused in every case. With it, all three draw. The Drawing Pad received the first rule as well: a new touch finishes a held one whose phase is over. The house rules keep the lesson in one line: "The canvas never waits on a touch's end to let the Pencil in."

Everything Else the Pencil Owns

  • Double-tap and squeeze. A UIPencilInteraction on the canvas forwards both. Each one is configured separately to one of eight actions: As the iPad Says (the system's preferred tap or squeeze action), Brush and Eraser, Previous Tool, Colors, QuickMenu, Eyedropper, Undo, Nothing. A squeeze acts when it is released, and the eyedropper and QuickMenu act at the point where the Pencil is hovering.
  • Hover. A UIHoverGestureRecognizer tracks the hovering Pencil. In paint mode it draws the brush's outline, if the brush cursor is on, and Liquify always shows its size. With hover gestures on, which is the default, a two-finger pinch sizes the brush and a one-finger slide sets its opacity while the Pencil hovers. Pan and rotation are refused for that time, so the canvas holds still.
  • Palm Support. Off until turned on. Standard treats a touch whose majorRadius is 42 points or more as a resting hand, and Fine lowers that to 30, at the cost of a broad fingertip now and then (second code block). Once a touch is judged a palm it stays one until it lifts. It never acts, never reaches a navigation gesture, and does not count as a finger. A finger that spreads into a palm mid-stroke has its stroke dropped.

What the Tests Cannot Say

Every behavior in this lesson begins with a real Apple Pencil, and the test gate runs on an iPad simulator, which has none. The de3bef7 commit says it in its last lines: "The lost end itself was not reproduced; the simulator has no Pencil. Dad's iPad is the witness." The coop history rows for those rounds keep the same honesty. For squeeze and double-tap, the gestures "were tested through the model". Pencil hover, so the hover gestures too, was tested only through the model. A real palm, and the lost touch end on Dad's iPad, were not exercised at all. What the suites do prove is narrower than it sounds. Every Pencil gesture action and the palm thresholds have unit tests, and the commit's existing painting, selection, transform, fill and taper UI tests still passed. No test drops a touch's end on purpose; the sketch above is the closest thing to one. Whether the iPad ever loses an end again, and whether 42 points matches a real hand, are hardware facts. Track 8 turns that boundary into a practice.

Never let state wait on an event you cannot guarantee. If a slot is cleared only by a callback, give the next arrival a way to check whether the slot's owner is really still there. Lost ends are rare, but a rare failure with no way out reads as a broken app.

Code

Canvas/CanvasMetalView.swift, verbatim (doc comment omitted): reclaimCanvas, called first in touchesBegan·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, verbatim (trimmed: the title switch omitted): Palm Support's thresholds·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
        }
    }
A minimal sketch (compiled with swiftc and run): the one-touch slot before and after reclaimCanvas, for three shapes of a lost end·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

Find a piece of state in one of your projects that is set by one event and cleared only by another: a busy flag cleared on completion, a lock released in a callback, a drag in progress cleared on mouse-up, a modal cleared on dismiss. Write down what happens if the clearing event never arrives. Then add a reclaim check at the next arrival that can tell, from information it already has, whether the old owner is really still there, and write a test that drops the clearing event on purpose.
Hint
The reclaim check must not need the lost event. Look for evidence the system keeps anyway: a phase or status field, a timestamp you can compare, the identity of the newcomer (one Pencil means an earlier Pencil touch must be over). Then decide what reclaiming does to the old owner's work: finish it as if it ended normally, or throw it away.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.