Inkling

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

  1. Open Xcode: Launch Xcode and select Create a new Xcode project.
  2. Choose Template: Select App under the iOS tab and click Next.
  3. 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:

Swift
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. No DispatchQueue.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 from canvas.undoManager, and the undoCount joins 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 the PencilKit palette stays alive. You’ll attach it in the representable once the canvas is in a window.
  • Combine subscriptions to NSUndoManager notifications
    PencilKit registers 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 canUndo likely changed
      Each event does undoCount += 1, which is enough to refresh the toolbar state.

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:

  1. Creates (or rather, exposes) the one shared PKCanvasView from your controller
  2. Attaches the native PKToolPicker once the canvas is in a window
  3. Keeps state in sync: tool palette visibility and canvas background color
Swift
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 in ContentView) that controls whether the PencilKit palette should be visible. Tapping your Tools-button toggles this.
  • @Binding var canvasColor: Color
    SwiftUI owns the color; the representable applies it to PKCanvasView.backgroundColor and 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
    If controller.toolPicker already 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 a PKToolPicker(), 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 undoCount notifications; 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:

Swift
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, typically UIImage, 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:

Swift
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. makeUIViewController creates the picker, and updateUIViewController keeps 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.
  • onDismiss closure
    Optional callback used to trigger updates — for instance, setting the new background color on your PKCanvasView when the picker closes.

How you use it in the app

In your main view (ContentView), you connect it like this:

Swift
.sheet(isPresented: $showCanvasColorPicker) {
    SystemColorPicker(color: $canvasColor) {
        controller.canvas.backgroundColor = UIColor(canvasColor)
    }
}

So when the user taps the Background button in the toolbar:

  1. The sheet opens immediately with the native iOS color picker.
  2. The picker’s selection updates canvasColor live.
  3. 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 PencilKit drawing data (PKDrawing.dataRepresentation())
  • the background color (as RGBA)

Here’s the implementation

Swift
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 a UIColor to normalized RGBA components and back.
    Storing components (instead of e.g. hex strings) keeps the file simple and accurate.
  • DraftStore directory
    All drafts live in Documents/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 JSONEncoder and writes atomically (safe against crashes)
  • Load:
    • Reads JSON
    • Decodes DraftFile
    • Rebuilds PKDrawing from drawingData and restores the UIColor
    • Returns both for your UI to apply to the canvas and your SwiftUI canvasColor
  • List & sort
    Filters only .inkling files 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
Swift
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.
  • drafts is 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):

Swift
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:

Swift
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

  • NavigationStack provides the top bar that hosts the toolbar controls.
  • PencilCanvasRepresentable embeds your one PKCanvasView, controls the native PKToolPicker, and applies canvasColor.
  • .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’s undoCount (from Step 1) nudges SwiftUI to re-check these flags after changes.
  • Tools: Toggles the PencilKit tool palette (slides in from the bottom).
  • Background: Presents the system color picker (Step 4). The chosen canvasColor is applied back to the PKCanvasView.
  • Delete: Resets to a fresh PKDrawing().
  • Save: Persists an .inkling file 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 NSPhotoLibraryAddUsageDescription to Info.plist and pass a UIImage to ActivityView.
  • Undo/Redo disabled? Ensure you observe UndoManager notifications (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 a ZStack.

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 PencilKit with SwiftUI using UIViewRepresentable to display a full-screen, interactive drawing canvas.
  • Use the native PencilKit tool 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