```html SwiftUI: How to Build a Drawing Canvas (iOS 17+, 2026)

How to Build a Drawing Canvas in SwiftUI

iOS 17+ Xcode 16+ Advanced APIs: PencilKit · PKCanvasView · PKToolPicker Updated: May 12, 2026
TL;DR

PencilKit has no native SwiftUI view, so wrap PKCanvasView in a UIViewRepresentable, bind a PKDrawing for state, and attach PKToolPicker so the floating palette appears automatically.

import SwiftUI
import PencilKit

struct CanvasRepresentable: UIViewRepresentable {
    @Binding var drawing: PKDrawing
    let toolPicker: PKToolPicker

    func makeUIView(context: Context) -> PKCanvasView {
        let canvas = PKCanvasView()
        canvas.drawing = drawing
        canvas.drawingPolicy = .anyInput          // finger + Apple Pencil
        toolPicker.setVisible(true, forFirstResponder: canvas)
        toolPicker.addObserver(canvas)
        canvas.delegate = context.coordinator
        canvas.becomeFirstResponder()
        return canvas
    }

    func updateUIView(_ canvas: PKCanvasView, context: Context) {
        if canvas.drawing != drawing { canvas.drawing = drawing }
    }

    func makeCoordinator() -> Coordinator { Coordinator(self) }

    class Coordinator: NSObject, PKCanvasViewDelegate {
        var parent: CanvasRepresentable
        init(_ parent: CanvasRepresentable) { self.parent = parent }
        func canvasViewDrawingDidChange(_ cv: PKCanvasView) {
            parent.drawing = cv.drawing
        }
    }
}

Full implementation

The complete canvas view wraps CanvasRepresentable inside a NavigationStack with toolbar buttons for clearing the canvas and exporting the drawing as a UIImage saved to the photo library. A PKToolPicker is created once as @State and shared so the floating palette persists across re-renders. An undo gesture recognizer is wired to the standard UndoManager via the environment.

import SwiftUI
import PencilKit

// MARK: - UIViewRepresentable bridge

struct CanvasRepresentable: UIViewRepresentable {
    @Binding var drawing: PKDrawing
    let toolPicker: PKToolPicker

    func makeUIView(context: Context) -> PKCanvasView {
        let canvas = PKCanvasView()
        canvas.drawing = drawing
        canvas.drawingPolicy = .anyInput   // accept finger strokes on non-Pencil devices
        canvas.backgroundColor = .clear
        canvas.isOpaque = false
        canvas.delegate = context.coordinator

        // Attach the floating tool picker to this canvas
        toolPicker.setVisible(true, forFirstResponder: canvas)
        toolPicker.addObserver(canvas)
        canvas.becomeFirstResponder()
        return canvas
    }

    func updateUIView(_ canvas: PKCanvasView, context: Context) {
        // Keep external state changes (e.g. "Clear" button) reflected in the canvas
        if canvas.drawing != drawing {
            canvas.drawing = drawing
        }
    }

    func makeCoordinator() -> Coordinator { Coordinator(self) }

    // MARK: Coordinator — forwards canvas delegate events back to SwiftUI

    class Coordinator: NSObject, PKCanvasViewDelegate {
        var parent: CanvasRepresentable
        init(_ parent: CanvasRepresentable) { self.parent = parent }

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

// MARK: - SwiftUI shell

struct DrawingCanvasView: View {
    @State private var drawing = PKDrawing()
    @State private var toolPicker = PKToolPicker()
    @State private var showExportConfirmation = false

    var body: some View {
        NavigationStack {
            ZStack {
                // Subtle grid background so "white on white" strokes are visible
                Color(.systemGray6).ignoresSafeArea()

                CanvasRepresentable(drawing: $drawing, toolPicker: toolPicker)
                    .background(Color.white)
                    .clipShape(RoundedRectangle(cornerRadius: 12))
                    .padding()
                    .ignoresSafeArea(edges: .bottom)
            }
            .navigationTitle("Canvas")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .topBarLeading) {
                    Button(role: .destructive) {
                        withAnimation { drawing = PKDrawing() }
                    } label: {
                        Label("Clear", systemImage: "trash")
                    }
                    .disabled(drawing.strokes.isEmpty)
                    .accessibilityLabel("Clear canvas")
                }

                ToolbarItem(placement: .topBarTrailing) {
                    Button {
                        exportToPhotos()
                    } label: {
                        Label("Export", systemImage: "square.and.arrow.up")
                    }
                    .disabled(drawing.strokes.isEmpty)
                    .accessibilityLabel("Export drawing to Photos")
                }
            }
            .alert("Saved to Photos", isPresented: $showExportConfirmation) {
                Button("OK", role: .cancel) {}
            }
        }
    }

    // MARK: Export

    private func exportToPhotos() {
        let bounds = drawing.bounds.isEmpty
            ? CGRect(x: 0, y: 0, width: 1024, height: 768)
            : drawing.bounds.insetBy(dx: -20, dy: -20)
        let scale = UIScreen.main.scale
        let image = drawing.image(from: bounds, scale: scale)
        UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil)
        showExportConfirmation = true
    }
}

// MARK: - Preview

#Preview {
    DrawingCanvasView()
}

How it works

  1. UIViewRepresentable bridge — PencilKit's PKCanvasView is a UIKit view. The CanvasRepresentable struct creates it in makeUIView, configures policies, and returns it to the SwiftUI layout engine, which sizes it to fill its parent.
  2. PKToolPicker attachment — Calling toolPicker.setVisible(true, forFirstResponder: canvas) followed by canvas.becomeFirstResponder() makes the floating radial tool palette appear. The picker is declared as @State in the SwiftUI view so it is created only once and shared across re-renders.
  3. Bidirectional @Binding sync — The Coordinator conforms to PKCanvasViewDelegate and writes canvasView.drawing back to the binding in canvasViewDrawingDidChange. Going the other direction, updateUIView mirrors external drawing changes (like the Clear button) into the canvas.
  4. drawingPolicy = .anyInput — On devices without Apple Pencil this allows finger drawing. To restrict to Pencil-only (common in note-taking apps where finger pans the canvas), set .pencilOnly instead.
  5. Export via PKDrawing.image(from:scale:) — The export method in lines 66–73 computes a tight bounding box around the strokes (with 20 pt padding), then rasterizes the drawing at the device's native pixel density using drawing.image(from:scale:), yielding a crisp UIImage ready for the photo library or sharing.

Variants

Undo / Redo toolbar buttons

PencilKit manages its own UndoManager on the canvas view. Expose it via the coordinator and wire it to toolbar buttons for apps that hide the tool picker's built-in undo.

// In the Coordinator, expose the canvas's undoManager
class Coordinator: NSObject, PKCanvasViewDelegate {
    var canvasView: PKCanvasView?

    func canUndo() -> Bool { canvasView?.undoManager?.canUndo ?? false }
    func canRedo() -> Bool { canvasView?.undoManager?.canRedo ?? false }
    func undo() { canvasView?.undoManager?.undo() }
    func redo() { canvasView?.undoManager?.redo() }
}

// In makeUIView, store a weak reference:
context.coordinator.canvasView = canvas

// In the SwiftUI toolbar:
ToolbarItemGroup(placement: .bottomBar) {
    Button(action: { coordinator.undo() }) {
        Image(systemName: "arrow.uturn.backward")
    }
    .accessibilityLabel("Undo")

    Button(action: { coordinator.redo() }) {
        Image(systemName: "arrow.uturn.forward")
    }
    .accessibilityLabel("Redo")
}

Persisting drawings with SwiftData

Encode a PKDrawing to Data via drawing.dataRepresentation() and store it in a @Model class as a Data property. Restore with PKDrawing(data: savedData). Because PKDrawing can be multi-megabyte, store it as an external file by annotating the property @Attribute(.externalStorage).

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement a drawing canvas in SwiftUI for iOS 17+.
Use PencilKit, PKCanvasView, and PKToolPicker.
Wrap the canvas in a UIViewRepresentable with @Binding to PKDrawing.
Accept both Apple Pencil and finger input (drawingPolicy = .anyInput).
Add toolbar buttons for Clear and Export to Photos.
Make it accessible (VoiceOver labels on all toolbar buttons).
Persist the drawing using @Attribute(.externalStorage) in SwiftData.
Add a #Preview with realistic sample data (pre-loaded PKDrawing strokes).

In the Soarias Build phase, paste this prompt into the active canvas session — Claude Code will scaffold the UIViewRepresentable, wire the SwiftData model, and generate the preview in a single pass, leaving you to tune the tool picker presets and export format.

Related

FAQ

Does this work on iOS 16?

Mostly yes — PKCanvasView and PKToolPicker() (no-arg init) are available from iOS 14+. However, some PKToolPicker configuration APIs and ink types (like monoline) were added in later point releases. If you need iOS 16 support, audit your tool picker configuration against Apple's availability docs, but the core UIViewRepresentable pattern shown here works unchanged.

Can I disable finger drawing and use only Apple Pencil?

Yes — set canvas.drawingPolicy = .pencilOnly in makeUIView. This is the right default for note-taking or illustration apps where finger gestures should pan/zoom the canvas rather than draw. For mixed apps, .anyInput (used in this guide) accepts both. You can also set canvas.allowsFingerDrawing dynamically from SwiftUI via updateUIView to let users toggle the behaviour from a Settings screen.

What is the UIKit equivalent?

In a pure UIKit app you add PKCanvasView directly to your view hierarchy, set its delegate to a UIViewController that conforms to PKCanvasViewDelegate, and call toolPicker.setVisible(true, forFirstResponder: canvasView) from viewDidAppear after canvasView.becomeFirstResponder(). The state management is the same; you just use properties and delegate callbacks instead of SwiftUI bindings and @State.

Last reviewed: 2026-05-12 by the Soarias team.

```