How to Build a Drawing Canvas in SwiftUI
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
-
UIViewRepresentable bridge — PencilKit's
PKCanvasViewis a UIKit view. TheCanvasRepresentablestruct creates it inmakeUIView, configures policies, and returns it to the SwiftUI layout engine, which sizes it to fill its parent. -
PKToolPicker attachment — Calling
toolPicker.setVisible(true, forFirstResponder: canvas)followed bycanvas.becomeFirstResponder()makes the floating radial tool palette appear. The picker is declared as@Statein the SwiftUI view so it is created only once and shared across re-renders. -
Bidirectional @Binding sync — The
Coordinatorconforms toPKCanvasViewDelegateand writescanvasView.drawingback to the binding incanvasViewDrawingDidChange. Going the other direction,updateUIViewmirrors externaldrawingchanges (like the Clear button) into the canvas. -
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
.pencilOnlyinstead. -
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 crispUIImageready 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
-
iOS version floor:
PKToolPickergained the no-argument initialiser (PKToolPicker()) in iOS 14. The window-basedshared(for:)API is deprecated in iOS 17 — always use the direct initialiser when targeting iOS 17+. -
Re-render loop: Setting
canvas.drawing = drawinginsideupdateUIViewtriggers the delegate, which updates the binding, which callsupdateUIViewagain. Guard against this withif canvas.drawing != drawing(PKDrawing is Equatable) — which the code above already does. -
Export bounds edge case: A freshly created
PKDrawinghasbounds == .zero. Always provide a fallback rectangle before callingdrawing.image(from:scale:), otherwise you get a 0×0 image. -
Accessibility:
PKCanvasViewdoes not expose its ink strokes to VoiceOver. Add a supplementary description label above the canvas summarising the drawing's purpose, and include toolbar buttons with explicit.accessibilityLabel()strings so Motor Accessibility switch users can still clear and export.
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.