In this month’s code-along we will create a drawing app with PencilKit.
What you’ll build
- A full-screen PencilKit canvas with the native tool palette
- A liquid-glass top toolbar (Undo/Redo, Tools, Background, Delete, Save Draft, Drafts, Share)
- Change canvas background color via the system color picker
- Share as image (composited over the chosen background)
- Save multiple editable drafts (drawing + background color) and reopen them with thumbnails
The app will look like this:

Step 0: Set up your project
- Open Xcode: Launch Xcode and select Create a new Xcode project.
- Choose Template: Select App under the iOS tab and click Next.
- Name Your Project: Enter a name for your project, like
Inkling.- interface: SwiftUI
- language: Swift
Click Next, and then save your project.
When you open your project, you’ll see the already familiar standard code presenting a globe and the Text “Hello, world!” in the ContentView.swift.
Add to Info.plist (for saving to Photos via Share sheet):NSPhotoLibraryAddUsageDescription → “Allow saving your drawing to Photos.”
Step 1: Add a controller that holds the canvas and tool state
Before we can show a drawing surface in SwiftUI, we need a single source of truth that owns the UIKit pieces (PencilKit, PKCanvasView, PKToolPicker) and exposes just enough state for SwiftUI to react (e.g., enabling/disabling Undo/Redo).
Here’s the controller:
import Combine
import PencilKit
import UIKit
@MainActor
final class CanvasController: ObservableObject {
let canvas = PKCanvasView()
var toolPicker: PKToolPicker?
@Published var undoCount = 0
private var cancellables = Set<AnyCancellable>()
init() {
// Listen for undo/redo changes
NotificationCenter.default.publisher(for: .NSUndoManagerDidUndoChange)
.merge(with: NotificationCenter.default.publisher(for: .NSUndoManagerDidRedoChange))
.sink { [**weak** **self**] _ **in** **self**?.undoCount += 1 }
.store(in: &cancellables)
// Also when new actions are registered
NotificationCenter.default.publisher(for: .NSUndoManagerWillCloseUndoGroup)
.sink { [**weak** **self**] _ **in** **self**?.undoCount += 1 }
.store(in: &cancellables)
}
}
What each part means (and why it’s here):
@MainActor
Ensures all UI work in this controller runs on the main thread. NoDispatchQueue.main.async; Swift 6 handles it.ObservableObject+@Published var undoCount
We don’t need to publish the drawing itself (that’s huge). Instead, we publish a simple counter that increments whenever the undo stack changes. SwiftUI reads canUndo/canRedo fromcanvas.undoManager, and theundoCountjoins forces SwiftUI to recompute.disabled(...).let canvas = PKCanvasView()
You want one live canvas instance owned by this controller. SwiftUI wrappers will reference it—no recreating, no losing the undo stack.var toolPicker: PKToolPicker?
Keep a strong reference so thePencilKitpalette stays alive. You’ll attach it in the representable once the canvas is in a window.- Combine subscriptions to
NSUndoManagernotificationsPencilKitregisters undo operations on the canvas’undoManager. These notifications fire when:- a user undoes/redoes (
DidUndoChange/DidRedoChange) - an undo group closes (a new stroke was added), which means
canUndolikely changed
Each event doesundoCount += 1, which is enough to refresh the toolbar state.
- a user undoes/redoes (
With this controller in place, SwiftUI gets a stable bridge to UIKit: one canvas, one tool picker, and a tiny published signal to keep the toolbar reactive. Next step: wrap the canvas for SwiftUI and attach the PencilKit tool palette.
Step 2: Wrap PKCanvasView for `SwiftUI
SwiftUI can’t host PKCanvasView directly; we bridge it with UIViewRepresentable.
This wrapper does three things cleanly and safely:
- Creates (or rather, exposes) the one shared PKCanvasView from your controller
- Attaches the native
PKToolPickeronce the canvas is in a window - Keeps state in sync: tool palette visibility and canvas background color
import SwiftUI
import PencilKit
@MainActor
struct PencilCanvasRepresentable: UIViewRepresentable {
@ObservedObject var controller: CanvasController
var showsToolPicker: Bool
@Binding var canvasColor: Color
func makeUIView(context: Context) -> PKCanvasView {
let canvas = controller.canvas
canvas.drawingPolicy = .anyInput
canvas.backgroundColor = UIColor(canvasColor) // initial color
return canvas
}
func updateUIView(_ uiView: PKCanvasView, context: Context) {
if uiView.window != nil {
attachToolPickerIfNeeded(to: uiView)
}
if let picker = controller.toolPicker {
picker.setVisible(showsToolPicker, forFirstResponder: uiView)
if showsToolPicker {
Task { @MainActor in uiView.becomeFirstResponder() }
}
}
}
private func attachToolPickerIfNeeded(to canvas: PKCanvasView) {
guard canvas.window != nil else { return }
if let picker = controller.toolPicker {
picker.addObserver(canvas)
picker.setVisible(true, forFirstResponder: canvas)
return
}
let picker = PKToolPicker()
controller.toolPicker = picker
picker.addObserver(canvas)
picker.setVisible(true, forFirstResponder: canvas)
}
}
Why @MainActor on the representable?
UIKit views must be touched on the main thread. Marking the whole struct @MainActor guarantees makeUIView, updateUIView, and the tool-picker wiring all run on the main actor
Inputs and data flow:
@ObservedObject var controller
You don’t create a new canvas here; you reuse the one from the controller. That preserves the undo stack, tool state, and performance.var showsToolPicker: Bool
A simple SwiftUI boolean (owned inContentView) that controls whether thePencilKitpalette should be visible. Tapping your Tools-button toggles this.@Binding var canvasColor: Color
SwiftUI owns the color; the representable applies it toPKCanvasView.backgroundColorand updates when it changes.
makeUIView(context:)
let canvas = controller.canvas
Grabs the already-created canvas so we don’t lose state.canvas.drawingPolicy = .anyInput
Allows both Apple Pencil and finger input. If you want Pencil-only, use.pencilOnly.canvas.backgroundColor = UIColor(canvasColor)
Sets the initial background to your current SwiftUI color.- Returns the canvas to SwiftUI; the view hierarchy takes it from there.
Note: We don’t attach the tool picker here because the canvas might not yet be in a window. Attaching too early can no-op or fail.
updateUIView(_:context:)
This is called whenever SwiftUI thinks the view should reconcile state (props changed, layout changes, etc.).
if uiView.window != nil { attachToolPickerIfNeeded(to:) }
We only attach once the canvas is in a window. That guarantees the picker can anchor itself to the correct scene/window.picker.setVisible(showsToolPicker, forFirstResponder: uiView)
Tells the system palette to show/hide for this canvas.if showsToolPicker { Task { uiView.becomeFirstResponder() } }
The palette only follows the first responder canvas. We hop to the next main runloop tick to safely make the canvas first responder after visibility is applied.
This pattern is idempotent: calling it on every update is safe; it reuses the same picker instance.
attachToolPickerIfNeeded(to:)
guard canvas.window != nil else { return }
Hard stop until there’s a window—critical for correct picker behavior.- Reuse if available
Ifcontroller.toolPickeralready exists, just:addObserver(canvas)— so the picker can track selection, color, etc.setVisible(true, forFirstResponder: canvas)— ensures it’s associated with this canvas
- Create if missing
If not present, create aPKToolPicker(), store it strongly on the controller, then attach as above.
Alternative: you can use
PKToolPicker.shared(for: window)if you want the system-shared picker bound to a specific window. Your custom instance is also fine and avoids deprecated overloads.
Common pitfalls this design avoids
- Recreating canvases: would reset undo history and break tool focus. We reuse one
PKCanvasView. - Attaching picker too early: without a window, the picker can’t attach. We wait and check.
- Picker disappearing: if you don’t keep a strong reference (
controller.toolPicker), the palette can be deallocated. - Unresponsive toolbar state: handled in Step 1 via
undoCountnotifications; not in the representable.
In short
The representable is a thin, safe bridge. It hosts your single PKCanvasView, wires up the native PencilKit tools at the right time, and reflects SwiftUI state (palette visibility + background color) without ever fighting the UIKit lifecycle.
Step 3: System share sheet helper (for “Save image”)
When you want users to export their drawings — for example, to save them in Photos, send them via Messages, or share them to another app — the easiest native way on iOS is to use Apple’s built-in Share Sheet (UIActivityViewController).
However, UIActivityViewController is part of UIKit, not SwiftUI.
To use it in a SwiftUI app, we wrap it in a lightweight UIViewControllerRepresentable, which lets SwiftUI present any UIKit view controller as a native sheet:
import SwiftUI
struct ActivityView: UIViewControllerRepresentable {
let items: [Any]
func makeUIViewController(context: Context) -> UIActivityViewController {
UIActivityViewController(activityItems: items, applicationActivities: nil)
}
func updateUIViewController(_ vc: UIActivityViewController, context: Context) {}
}
UIViewControllerRepresentable
This protocol bridges UIKit view controllers into SwiftUI. It has two required methods:makeUIViewController(context:): creates the UIKit controller.updateUIViewController(_:context:): updates it if SwiftUI state changes (unused here).
UIActivityViewController
Apple’s native “Share Sheet.” It automatically detects what kind of content you’re sharing (image, text, file, URL) and presents appropriate system actions: Save Image, Copy, AirDrop, Messages, Mail, and so on.activityItems
The array of items you want to share — in this app, typicallyUIImage, but you can also share text or URLs. The system inspects these items and offers only the relevant actions.
Why we need this wrapper?
SwiftUI doesn’t yet provide a built-in modifier. By creating this one small struct, we can reuse it across the entire app.
Step 4: Native color picker that opens immediately
SwiftUI includes a built-in ColorPicker, but by design, it behaves like a button — it shows only a small color circle that the user must tap again to open the actual picker.
In a drawing app, however, we want the color picker to appear immediately when the user taps a toolbar button.
To achieve that, we wrap the UIKit version, UIColorPickerViewController, inside a UIViewControllerRepresentable.
This lets SwiftUI present the native iOS color picker directly — the same one used in Apple’s Notes and Markup apps.
Here’s the complete implementation:
import SwiftUI
import UIKit
struct SystemColorPicker: UIViewControllerRepresentable {
@Binding var color: Color
var onDismiss: (() -> Void)? = nil
func makeUIViewController(context: Context) -> UIColorPickerViewController {
let picker = UIColorPickerViewController()
picker.selectedColor = UIColor(color)
picker.delegate = context.coordinator
return picker
}
func updateUIViewController(_ uiViewController: UIColorPickerViewController, context: Context) {
uiViewController.selectedColor = UIColor(color)
}
func makeCoordinator() -> Coordinator { Coordinator(self) }
class Coordinator: NSObject, UIColorPickerViewControllerDelegate {
var parent: SystemColorPicker
init(_ parent: SystemColorPicker) { self.parent = parent }
func colorPickerViewControllerDidFinish(_ viewController: UIColorPickerViewController) {
parent.onDismiss?()
}
func colorPickerViewControllerDidSelectColor(_ viewController: UIColorPickerViewController) {
parent.color = Color(viewController.selectedColor)
}
}
}
How this works:
UIViewControllerRepresentable
Bridges a UIKit controller into SwiftUI.makeUIViewControllercreates the picker, andupdateUIViewControllerkeeps it in sync with SwiftUI state.@Binding var color
Two-way binding between the UIKit color picker and SwiftUI’s Color. When the user selects a new color, the coordinator updates the SwiftUI binding.UIColorPickerViewController
Swift’s system color picker — supports the full color wheel, sliders, and recently used colors. It instantly appears when presented in a.sheet.- Coordinator pattern
The delegate (UIColorPickerViewControllerDelegate) notifies SwiftUI when the color changes or the picker is dismissed. This keeps the selected color synchronised with your app’s current canvas background. onDismissclosure
Optional callback used to trigger updates — for instance, setting the new background color on yourPKCanvasViewwhen the picker closes.
How you use it in the app
In your main view (ContentView), you connect it like this:
.sheet(isPresented: $showCanvasColorPicker) {
SystemColorPicker(color: $canvasColor) {
controller.canvas.backgroundColor = UIColor(canvasColor)
}
}
So when the user taps the Background button in the toolbar:
- The sheet opens immediately with the native iOS color picker.
- The picker’s selection updates
canvasColorlive. - The onDismiss closure applies the chosen color to your
PKCanvasView.
In short, this wrapper gives your app a professional, native color-picking experience, fully integrated into SwiftUI.
Step 5: Draft storage that includes background color
PKDrawing.dataRepresentation() saves strokes only (vector ink). It does not include your canvas color.
To reopen drafts exactly as the user left them, we persist both pieces together in a tiny, portable file.
We’ll write one file per draft (*.inkling) that bundles:
- the
PencilKitdrawing data (PKDrawing.dataRepresentation()) - the background color (as RGBA)
Here’s the implementation
import UIKit
import PencilKit
private struct DraftFile: Codable {
let drawingData: Data
let color: RGBA
struct RGBA: Codable { let r: Double, g: Double, b: Double, a: Double }
}
private extension DraftFile.RGBA {
init(_ uiColor: UIColor) {
var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
uiColor.getRed(&r, green: &g, blue: &b, alpha: &a)
self.init(r: .init(r), g: .init(g), b: .init(b), a: .init(a))
}
var uiColor: UIColor { UIColor(red: r, green: g, blue: b, alpha: a) }
}
enum DraftStore {
private static var draftsDir: URL {
let doc = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
return doc.appendingPathComponent("Drafts", isDirectory: true)
}
static func ensureDir() {
try? FileManager.default.createDirectory(at: draftsDir, withIntermediateDirectories: true)
}
static func save(_ drawing: PKDrawing, color: UIColor, named name: String) throws {
ensureDir()
let sanitized = name.isEmpty ? "Untitled" : name.replacingOccurrences(of: "/", with: "-")
let stamp = ISO8601DateFormatter().string(from: Date())
let url = draftsDir.appendingPathComponent("\(sanitized)-\(stamp).inkling")
let payload = DraftFile(drawingData: drawing.dataRepresentation(), color: .init(color))
let data = try JSONEncoder().encode(payload)
try data.write(to: url, options: .atomic)
}
static func load(from url: URL) -> (drawing: PKDrawing, color: UIColor)? {
guard let data = try? Data(contentsOf: url),
let payload = try? JSONDecoder().decode(DraftFile.self, from: data),
let drawing = try? PKDrawing(data: payload.drawingData)
else { return nil }
return (drawing, payload.color.uiColor)
}
static func list() -> [URL] {
ensureDir()
let urls = (try? FileManager.default.contentsOfDirectory(
at: draftsDir, includingPropertiesForKeys: [.contentModificationDateKey],
options: [.skipsHiddenFiles]
)) ?? []
return urls.filter { $0.pathExtension == "inkling" }
.sorted {
let d0 = (try? $0.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
let d1 = (try? $1.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
return d0 > d1
}
}
static func delete(at url: URL) {
try? FileManager.default.removeItem(at: url)
}
}
What each part does (and why)
DraftFile: Codable
A tiny, versionable envelope that holds:- drawingData: raw bytes from PKDrawing.dataRepresentation() (lossless, fully editable)
- color: an RGBA struct (Double components for stability/precision)
- RGBA ↔︎ UIColor conversion
The extension converts aUIColorto normalized RGBA components and back.
Storing components (instead of e.g. hex strings) keeps the file simple and accurate. DraftStoredirectory
All drafts live inDocuments/Drafts/.ensureDir()creates it on first use.
Using a dedicated subfolder keeps your app’s documents tidy and easy to back up.- Save:
- Sanitizes the user’s draft name (removes
/) - Appends a timestamp for uniqueness
- Uses a custom extension:
.inkling - Encodes DraftFile via
JSONEncoderand writes atomically (safe against crashes)
- Sanitizes the user’s draft name (removes
- Load:
- Reads JSON
- Decodes DraftFile
- Rebuilds
PKDrawingfrom drawingData and restores theUIColor - Returns both for your UI to apply to the canvas and your SwiftUI
canvasColor
- List & sort
Filters only.inklingfiles and sorts by last modified descending so the most recent drafts appear first. - Delete
Removes the chosen draft file from disk.
Why not just PNG/JPEG?
Because a flattened image loses editability (you can’t change strokes later).
By storing the PencilKit vector data, users can reload a draft and continue drawing, with undo/redo and lasso intact. The color in the envelope guarantees the reopened canvas looks exactly the same.
With this step, your app supports multiple editable drafts that round-trip perfectly—a key feature that moves your drawing app from demo to product.
Step 6: Draft browser with thumbnails
Now that drafts are saved as single files (.inkling) containing both the PencilKit data and the background color, we need a UI to browse, preview, open, and delete them. This sheet is a focused, reusable picker that:
- Lists files returned by
DraftStore.list() - Shows a thumbnail that faithfully reflects the saved background color and ink
- Calls back with the selected URL so the caller can load both drawing and color
import SwiftUI
import PencilKit
struct DraftsSheet: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.displayScale) private var scale
let onPick: (URL) -> Void
@State private var drafts: [URL] = DraftStore.list()
@State private var toDelete: URL?
private func name(for url: URL) -> String {
url.deletingPathExtension().lastPathComponent
}
private func modifiedDate(for url: URL) -> Date {
(try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? Date()
}
var body: some View {
NavigationStack {
List {
ForEach(drafts, id: \.self) { url in
HStack(spacing: 12) {
if let thumb = thumbnail(for: url, max: 80, scale: scale) {
Image(uiImage: thumb)
.resizable().frame(width: 56, height: 56).cornerRadius(8)
} else {
Rectangle().fill(Color.secondary.opacity(0.2))
.frame(width: 56, height: 56).cornerRadius(8)
}
VStack(alignment: .leading) {
Text(name(for: url)).font(.headline)
Text(modifiedDate(for: url).formatted(date: .abbreviated, time: .shortened))
.font(.caption).foregroundStyle(.secondary)
}
Spacer()
}
.contentShape(Rectangle())
.onTapGesture { onPick(url); dismiss() }
.swipeActions {
Button(role: .destructive) { toDelete = url } label: {
Label("Delete", systemImage: "trash")
}
}
}
}
.navigationTitle("Drafts")
.toolbar {
ToolbarItem(placement: .navigationBarLeading) { Button("Close") { dismiss() } }
ToolbarItem(placement: .navigationBarTrailing) {
Button { drafts = DraftStore.list() } label: { Image(systemName: "arrow.clockwise") }
}
}
.alert("Delete draft?", isPresented: Binding(get: { toDelete != nil }, set: { _ in })) {
Button("Cancel", role: .cancel) { toDelete = nil }
Button("Delete", role: .destructive) {
if let url = toDelete { DraftStore.delete(at: url) }
drafts = DraftStore.list()
toDelete = nil
}
} message: { Text(name(for: toDelete ?? URL(fileURLWithPath: ""))) }
}
}
}
What’s happening here:
@Environment(\.displayScale)gives the correct pixel scale for crisp thumbnails.draftsis loaded from disk once; the refresh button reloads after saves/deletes.- Each row is a big tap target (
.contentShape(Rectangle())), shows a thumbnail + name + last modified time. - “Swipe to delete” uses iOS-native destructive actions and asks for confirmation.
The thumbnail renderer (ink over saved background):
import UIKit
import PencilKit
func thumbnail(for url: URL, max: CGFloat = 80, scale: CGFloat) -> UIImage? {
guard let loaded = DraftStore.load(from: url) else { return nil }
let drawing = loaded.drawing
let bg = loaded.color
let bounds = drawing.bounds.isEmpty
? CGRect(origin: .zero, size: .init(width: max, height: max))
: drawing.bounds.insetBy(dx: -6, dy: -6)
let ink = drawing.image(from: bounds, scale: scale)
let renderer = UIGraphicsImageRenderer(size: .init(width: max, height: max))
return renderer.image { _ in
bg.setFill()
UIBezierPath(rect: CGRect(origin: .zero, size: .init(width: max, height: max))).fill()
let aspect = min(max / ink.size.width, max / ink.size.height)
let sz = CGSize(width: ink.size.width * aspect, height: ink.size.height * aspect)
let origin = CGPoint(x: (max - sz.width)/2, y: (max - sz.height)/2)
ink.draw(in: CGRect(origin: origin, size: sz))
}
}
Why this works:
PKDrawing.image(from:scale:)returns the ink only (transparent background).- We first paint the saved background color, then draw the ink on top — exactly how your full export works — so the thumbnail matches the draft.
- We compute an aspect-fit rect so drawings of any size look centered and clean in a fixed square.
With this step, your app now has a pleasant, native-feeling Drafts browser that preserves fidelity (ink + background) and keeps the flow fast and obvious for users.
Step 7: Wrapping all together in ContentView
Now that you’ve built the core pieces (controller, canvas wrapper, share helper, color picker, draft storage, and drafts browser), it’s time to assemble the app’s main screen. ContentView wires up the canvas, the liquid-glass toolbar, sheets/alerts, and the export logic.
Here is the complete ContentView first, then we’ll walk through it:
import SwiftUI
import PencilKit
struct ContentView: View {
@StateObject private var controller = CanvasController()
@State private var showShare = false
@State private var exportImage: UIImage?
@State private var showDrafts = false
@State private var showSave = false
@State private var draftName = ""
@State private var showToolPicker = false
@State private var canvasColor: Color = .white
@State private var showCanvasColorPicker = false
@Environment(\.displayScale) private var displayScale
var body: some View {
NavigationStack {
PencilCanvasRepresentable(
controller: controller,
showsToolPicker: showToolPicker,
canvasColor: $canvasColor
)
.ignoresSafeArea(edges: .all) // full-bleed canvas under the bar
.toolbar {
ToolbarItemGroup(placement: .topBarLeading) {
Button { controller.canvas.undoManager?.undo() } label: {
Image(systemName: "arrow.uturn.backward")
}
.disabled(!(controller.canvas.undoManager?.canUndo ?? false))
.onChange(of: controller.undoCount) { } // re-evaluate
Button { controller.canvas.undoManager?.redo() } label: {
Image(systemName: "arrow.uturn.forward")
}
.disabled(!(controller.canvas.undoManager?.canRedo ?? false))
.onChange(of: controller.undoCount) { }
}
ToolbarItemGroup(placement: .topBarTrailing) {
Button("Tools", systemImage: showToolPicker ? "rectangle.bottomthird.inset.fill" : "pencil.tip") {
showToolPicker.toggle()
}
Button("Background", systemImage: "paintpalette") {
showCanvasColorPicker.toggle()
}
Button("Delete", systemImage: "trash") {
controller.canvas.drawing = PKDrawing()
}
Button("Save", systemImage: "tray.and.arrow.down") {
draftName = ""; showSave = true
}
Button("Drafts", systemImage: "folder") {
showDrafts = true
}
Button("Share", systemImage: "square.and.arrow.up") {
export()
}
}
}
.navigationBarTitleDisplayMode(.inline)
}
.sheet(isPresented: $showShare) {
if let image = exportImage { ActivityView(items: [image]) }
}
.sheet(isPresented: $showDrafts) {
DraftsSheet { url in
if let loaded = DraftStore.load(from: url) {
controller.canvas.drawing = loaded.drawing
let color = Color(loaded.color)
canvasColor = color
controller.canvas.backgroundColor = loaded.color
}
}
}
.alert("Save Draft", isPresented: $showSave) {
TextField("Name", text: $draftName)
Button("Cancel", role: .cancel) {}
Button("Save") {
try? DraftStore.save(
controller.canvas.drawing,
color: UIColor(canvasColor),
named: draftName
)
}
} message: { Text("Enter a name for this draft") }
.onAppear {
controller.canvas.backgroundColor = UIColor(canvasColor)
}
.onChange(of: canvasColor) { _, newValue in
controller.canvas.backgroundColor = UIColor(newValue)
}
.sheet(isPresented: $showCanvasColorPicker) {
SystemColorPicker(color: $canvasColor) {
controller.canvas.backgroundColor = UIColor(canvasColor)
}
}
}
// Export as flat image over the chosen background color
private func export() {
let canvas = controller.canvas
let bounds = canvas.drawing.bounds.isEmpty
? CGRect(origin: .zero, size: canvas.bounds.size)
: canvas.drawing.bounds.insetBy(dx: -8, dy: -8)
let inkOnly = canvas.drawing.image(from: bounds, scale: displayScale)
let bg = UIColor(canvasColor)
let renderer = UIGraphicsImageRenderer(size: inkOnly.size)
let final = renderer.image { _ in
bg.setFill()
UIBezierPath(rect: CGRect(origin: .zero, size: inkOnly.size)).fill()
inkOnly.draw(in: CGRect(origin: .zero, size: inkOnly.size))
}
exportImage = final
showShare = true
}
}
How this fits together
Navigation & canvas
NavigationStackprovides the top bar that hosts the toolbar controls.PencilCanvasRepresentableembeds your onePKCanvasView, controls the nativePKToolPicker, and appliescanvasColor..ignoresSafeArea(.all)lets the canvas paint behind the bar for a seamless, full-bleed look.
Toolbar actions
- Undo/Redo: Use
canvas.undoManager?.canUndo/canRedo. The controller’sundoCount(from Step 1) nudges SwiftUI to re-check these flags after changes. - Tools: Toggles the
PencilKittool palette (slides in from the bottom). - Background: Presents the system color picker (Step 4). The chosen
canvasColoris applied back to thePKCanvasView. - Delete: Resets to a fresh
PKDrawing(). - Save: Persists an
.inklingfile containing the drawing data and background color (Step 5). - Drafts: Opens the drafts browser with thumbnails (Step 6) and restores both the ink and the saved background color.
- Share: Exports a flat image that matches the canvas (ink composited over the selected background), then opens the Share Sheet (Step 3).
Sheets / Alert
- A sheet for Share (must pass a UIImage so “Save Image” appears).
- A sheet for Drafts (returns a URL; you load and apply both parts).
- A sheet for the system color picker (opens immediately).
- An alert to capture a draft name and call DraftStore.save.
Export details
PKDrawing.image(from:scale:)returns ink with transparent background.- Photos shows transparency as black, so we draw a colored rectangle first and then paint the ink on top. The result matches what you see on screen.
With ContentView in place, your app is fully assembled: a clean SwiftUI UI controlling a powerful PencilKit canvas, with native tool palette, color control, multiple editable drafts, and share-ready exports.
Troubleshooting and Tips
- No “Save Image” in Share sheet? Add
NSPhotoLibraryAddUsageDescriptiontoInfo.plistand pass aUIImageto ActivityView. - Undo/Redo disabled? Ensure you observe
UndoManagernotifications (undoCount) so SwiftUI re-evaluates the buttons. - Tool palette not showing? Canvas must be first responder; we call
becomeFirstResponder()after setting visibility. - Top area white? Use
.ignoresSafeArea(.all)on the canvas (as in the tutorial) or paint behind with aZStack.
Congratulations!
You’ve successfully created a fully functional SwiftUI drawing app powered by PencilKit! 🎉
Your app combines the best of SwiftUI’s declarative design and UIKit’s tools, allowing users to draw with Apple Pencil or touch, change the canvas color, save and reopen editable drafts, and export finished artwork through the system Share Sheet — all wrapped in a clean, modern, liquid-glass interface.
What you have learned
In this code-along, you’ve learned how to
- Integrate
PencilKitwith SwiftUI usingUIViewRepresentableto display a full-screen, interactive drawing canvas. - Use the native
PencilKittool palette and control its visibility from a SwiftUI toolbar. - Build a modern liquid-glass toolbar with Undo/Redo, background color, and sharing actions.
- Implement live canvas color changes through the system color picker.
- Export drawings correctly by compositing them over the selected background color.
- Save and reopen editable drafts, storing both the drawing data and background color.
You now know how to combine SwiftUI and PencilKit to create a clean, fully functional drawing app — a perfect foundation for creative extensions like layers, textures, or iCloud sync.
That’s a lot to cover in just one code-along! You made it this far, very well done! 🎉
That’s a wrap!
Keep learning, keep building, and let your curiosity guide you. Happy coding! ✨
An essential aspect of creativity is not being afraid to fail. — Edwin H. Land
Download the full project on GitHub: https://github.com/swiftandcurious/Inkling

