# PencilKit patterns

Longer recipes for the `pencilkit-drawing` skill ([../SKILL.md](../SKILL.md)).

## Listening to the tool picker

Register your controller next to the canvas so both hear about changes.

```swift
import PencilKit
import UIKit

final class StudioViewController: UIViewController, PKToolPickerObserver {
    private let board = PKCanvasView()
    private let palette = PKToolPicker()

    override func viewDidAppear(_ isAnimated: Bool) {
        super.viewDidAppear(isAnimated)
        palette.addObserver(board)
        palette.addObserver(self)
        palette.setVisible(true, forFirstResponder: board)
        board.becomeFirstResponder()
    }

    func toolPickerSelectedToolItemDidChange(_ toolPicker: PKToolPicker) {
        let chosen = toolPicker.selectedToolItem.identifier
        title = "Tool: \(chosen)"
    }

    func toolPickerVisibilityDidChange(_ toolPicker: PKToolPicker) {
        navigationController?.setToolbarHidden(toolPicker.isVisible, animated: true)
    }

    func toolPickerFramesObscuredDidChange(_ toolPicker: PKToolPicker) {
        let covered = toolPicker.frameObscured(in: view)
        board.contentInset.bottom = covered.isNull ? 0 : view.bounds.maxY - covered.minY
    }
}
```

The last callback keeps the bottom of the drawing reachable when the docked
palette covers part of the screen.

## A custom picker item

Requires iOS or iPadOS 18, visionOS 2, or Mac Catalyst 18.

```swift
@available(iOS 18.0, visionOS 2.0, *)
func makeStampPicker() -> PKToolPicker {
    var setup = PKToolPickerCustomItem.Configuration(identifier: "com.example.stamp",
                                                     name: "Stamp")
    setup.defaultColor = .systemOrange
    setup.allowsColorSelection = true
    setup.defaultWidth = 24
    setup.widthVariants = [
        16: UIImage(systemName: "circle.fill") ?? UIImage(),
        24: UIImage(systemName: "circle.circle.fill") ?? UIImage()
    ]
    setup.imageProvider = { item in
        (UIImage(systemName: "seal.fill") ?? UIImage()).withTintColor(item.color)
    }

    let stamp = PKToolPickerCustomItem(configuration: setup)
    return PKToolPicker(toolItems: [
        PKToolPickerInkingItem(type: .pen),
        stamp,
        PKToolPickerEraserItem(type: .bitmap)
    ])
}
```

## Delegate lifecycle

```swift
extension StudioViewController: PKCanvasViewDelegate {
    func canvasViewDidBeginUsingTool(_ canvasView: PKCanvasView) {
        autosaveTimer?.invalidate()           // user is mid-stroke
    }

    func canvasViewDidEndUsingTool(_ canvasView: PKCanvasView) {
        scheduleAutosave()
    }

    func canvasViewDrawingDidChange(_ canvasView: PKCanvasView) {
        hasPendingChanges = true
    }

    func canvasViewDidFinishRendering(_ canvasView: PKCanvasView) {
        refreshPreviewTile()                  // rendering has settled; safe to snapshot
    }
}
```

`autosaveTimer`, `hasPendingChanges`, `scheduleAutosave()` and
`refreshPreviewTile()` stand for the app's own state.

## Undo and redo

The canvas registers its edits with the responder chain's `UndoManager`, so
buttons only need to forward (for example from `viewDidLoad()`):

```swift
let stepBack = UIAction(title: "Undo", image: UIImage(systemName: "arrow.uturn.backward")) { [weak self] _ in
    self?.board.undoManager?.undo()
}
let stepForward = UIAction(title: "Redo", image: UIImage(systemName: "arrow.uturn.forward")) { [weak self] _ in
    self?.board.undoManager?.redo()
}
navigationItem.rightBarButtonItems = [
    UIBarButtonItem(primaryAction: stepForward),
    UIBarButtonItem(primaryAction: stepBack)
]
```

## Thumbnails with a fixed aspect ratio

```swift
func tile(for sketch: PKDrawing, size: CGSize, scale: CGFloat) -> UIImage? {
    let content = sketch.bounds
    guard !content.isEmpty else { return nil }

    let wanted = size.width / size.height
    var frame = content
    if content.width / content.height > wanted {
        let width = content.height * wanted
        frame = CGRect(x: content.midX - width / 2, y: content.minY,
                       width: width, height: content.height)
    } else {
        let height = content.width / wanted
        frame = CGRect(x: content.minX, y: content.midY - height / 2,
                       width: content.width, height: height)
    }
    return sketch.image(from: frame, scale: scale)
}
```

## Scoring a tracing exercise

A simple similarity score: sample both drawings evenly, then count how many
reference samples have an attempt sample nearby.

```swift
func tracingScore(target: PKDrawing, attempt: PKDrawing, radius: CGFloat = 20) -> Double {
    func samples(_ drawing: PKDrawing) -> [CGPoint] {
        drawing.strokes.flatMap { stroke in
            let evenly = stroke.path.interpolatedPoints(by: .distance(5))
            return evenly.map { $0.location.applying(stroke.transform) }
        }
    }
    let wanted = samples(target)
    let drawn = samples(attempt)
    guard !wanted.isEmpty else { return 0 }

    let hits = wanted.filter { goal in
        drawn.contains { hypot($0.x - goal.x, $0.y - goal.y) <= radius }
    }.count
    return Double(hits) / Double(wanted.count)
}
```

## Managing content versions

The version map and the cap from the main skill, in one place:

```swift
func describe(_ sketch: PKDrawing) -> String {
    switch sketch.requiredContentVersion {
    case .version1: "pen, pencil, marker"
    case .version2: "adds monoline, fountain pen, watercolor, crayon"
    case .version3: "adds the new fountain pen (iOS 17.5)"
    case .version4: "adds reed pen"
    @unknown default: "unknown future content"
    }
}

@MainActor
func limitToOldestClients(_ board: PKCanvasView, _ palette: PKToolPicker) {
    if #available(iOS 17.0, *) {                // drawings must open before iPadOS 17
        board.maximumSupportedContentVersion = .version1
        palette.maximumSupportedContentVersion = .version1
    }
}
```

### Deciding what to upload

Before sending a drawing through CloudKit or any cross-device channel, decide
what the oldest device will receive.

```swift
func payloadForOldestDevice(_ sketch: PKDrawing) -> PKDrawing? {
    switch sketch.requiredContentVersion {
    case .version1:
        return sketch
    case .version2, .version3, .version4:
        let simplified = downgradedCopy(of: sketch)
        guard simplified.requiredContentVersion == .version1 else {
            return nil          // send a thumbnail or an explanation instead
        }
        return simplified
    @unknown default:
        return nil
    }
}

func downgradedCopy(of sketch: PKDrawing) -> PKDrawing {
    let basicInk = PKInkingTool(.pen, color: .black, width: 4).ink
    let strokes = sketch.strokes.map { original -> PKStroke in
        var copy = original
        copy.ink = PKInk(basicInk.inkType, color: original.ink.color)
        return copy
    }
    return PKDrawing(strokes: strokes)
}
```

Swapping the ink is not always enough. The copies keep every other stroke
property, and only `requiredContentVersion` says what the result needs, which
is why the result is checked again before upload.

## A configurable SwiftUI wrapper

```swift
import PencilKit
import SwiftUI

struct SketchSurface: UIViewRepresentable {
    @Binding var drawing: PKDrawing
    var policy: PKCanvasViewDrawingPolicy = .anyInput
    var showsPalette = true

    func makeCoordinator() -> Coordinator { Coordinator(drawing: $drawing) }

    func makeUIView(context: Context) -> PKCanvasView {
        let canvas = PKCanvasView()
        canvas.backgroundColor = .clear
        canvas.isOpaque = false
        canvas.drawingPolicy = policy
        canvas.drawing = drawing
        canvas.delegate = context.coordinator
        context.coordinator.palette.addObserver(canvas)
        return canvas
    }

    func updateUIView(_ uiView: PKCanvasView, context: Context) {
        uiView.drawingPolicy = policy
        if uiView.drawing != drawing { uiView.drawing = drawing }
        let palette = context.coordinator.palette
        palette.setVisible(showsPalette, forFirstResponder: uiView)
        if showsPalette { uiView.becomeFirstResponder() }
    }

    final class Coordinator: NSObject, PKCanvasViewDelegate {
        let palette = PKToolPicker()
        private let drawing: Binding<PKDrawing>

        init(drawing: Binding<PKDrawing>) { self.drawing = drawing }

        func canvasViewDrawingDidChange(_ canvasView: PKCanvasView) {
            drawing.wrappedValue = canvasView.drawing
        }
    }
}
```

The clear background lets the surface sit over a photo, a PDF page or a
paper texture.
