Skip to content

Instantly share code, notes, and snippets.

@clarkezone
Last active November 19, 2025 11:46
Show Gist options
  • Select an option

  • Save clarkezone/68eb3ee13b5607782ceb2e20cece4ab3 to your computer and use it in GitHub Desktop.

Select an option

Save clarkezone/68eb3ee13b5607782ceb2e20cece4ab3 to your computer and use it in GitHub Desktop.
Basic SwiftUI wrapper for PaperKit
//
// ContentView.swift
// PaperKitPoCiOS
//
// Created by James Clarke on 7/15/25.
//
import SwiftUI
struct ContentView: View {
@State private var coordinator = PaperMarkupCoordinator()
@State private var isEditMode = true
var body: some View {
PaperMarkupView(
canvasSize: CGSize(width: 400, height: 800),
isEditable: isEditMode,
coordinator: coordinator
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.toolbar {
ToolbarItemGroup(placement: .navigationBarTrailing) {
Button(isEditMode ? "View" : "Edit") {
isEditMode.toggle()
}
Button("Clear") {
coordinator.clear()
}
Button("Undo") {
coordinator.undo()
}
}
}
.navigationTitle("Markup Canvas")
.navigationBarTitleDisplayMode(.inline)
}
}
#Preview {
ContentView()
}
//
// PaperMarkupView.swift
// PaperKitPoCiOS
//
// Created by James Clarke and Claude Code on 8/3/25.
//
// Description:
// This file defines the SwiftUI view for a PaperKit-based markup canvas.
// It uses a UIViewControllerRepresentable to wrap the PaperMarkupViewController
// from the PaperKit framework, allowing it to be used within a SwiftUI application.
//
// The implementation includes:
// - A coordinator to manage communication between SwiftUI and UIKit.
// - A wrapper view controller (`PaperMarkupWrapperViewController`) to host and
// configure the PaperKit canvas, PencilKit tool picker, and data persistence.
// - Automatic saving and loading of markup data to the Application Support directory.
//
import SwiftUI
import PaperKit
import PencilKit
import UIKit
// MARK: - PaperMarkupView
///
/// A SwiftUI view that wraps the `PaperMarkupWrapperViewController` to provide a
/// PaperKit drawing canvas.
///
/// ## SwiftUI-UIKit Bridge Pattern
/// This struct conforms to `UIViewControllerRepresentable`, which is SwiftUI's
/// standard protocol for integrating UIKit view controllers into SwiftUI views.
/// Think of it as a "translator" that lets SwiftUI communicate with UIKit components.
///
/// ## Why This Pattern Is Needed
/// PaperKit is a UIKit-based framework, but we want to use it in a SwiftUI app.
/// This bridge pattern allows us to:
/// - Wrap UIKit components for use in SwiftUI
/// - Handle the lifecycle differences between SwiftUI and UIKit
/// - Maintain proper state synchronization between the two frameworks
///
struct PaperMarkupView: UIViewControllerRepresentable {
let canvasSize: CGSize
let isEditable: Bool
let coordinator: PaperMarkupCoordinator
/// Initializes the SwiftUI view for the markup canvas.
/// - Parameters:
/// - canvasSize: The intrinsic size of the drawing canvas.
/// - isEditable: A flag to determine if the canvas is interactive.
/// - coordinator: The coordinator object to manage state and actions.
init(canvasSize: CGSize = CGSize(width: 400, height: 800), isEditable: Bool = true, coordinator: PaperMarkupCoordinator) {
self.canvasSize = canvasSize
self.isEditable = isEditable
self.coordinator = coordinator
}
/// **Required SwiftUI Protocol Method**
/// Creates the coordinator object that manages communication with the view controller.
///
/// **When Called**: SwiftUI calls this method ONCE when the view is first created.
/// **Purpose**: The coordinator acts as a delegate/communication bridge between
/// SwiftUI (declarative) and UIKit (imperative) worlds.
///
/// **Why Needed**: SwiftUI views are structs (value types) that get recreated,
/// but we need a persistent object (reference type) to maintain state and handle
/// callbacks from the UIKit side.
func makeCoordinator() -> PaperMarkupCoordinator {
coordinator
}
/// **Required SwiftUI Protocol Method**
/// Creates and configures the `PaperMarkupWrapperViewController` instance.
///
/// **When Called**: SwiftUI calls this method ONCE when the view first appears.
/// **Purpose**: This is where we create the actual UIKit view controller that
/// will be embedded in our SwiftUI view hierarchy.
///
/// **Important**: This method should only create and configure - never update.
/// Updates are handled by `updateUIViewController(_:context:)`.
func makeUIViewController(context: Context) -> PaperMarkupWrapperViewController {
let wrapperViewController = PaperMarkupWrapperViewController(
canvasSize: canvasSize,
isEditable: isEditable
)
// Link the coordinator to the newly created view controller.
context.coordinator.setWrapperViewController(wrapperViewController)
return wrapperViewController
}
/// **Required SwiftUI Protocol Method**
/// Updates the `PaperMarkupWrapperViewController` when the SwiftUI view's state changes.
///
/// **When Called**: SwiftUI calls this method whenever any `@State`, `@Binding`,
/// or other observed property changes (like `isEditable` in our case).
///
/// **Purpose**: This is SwiftUI's way of keeping the UIKit component in sync
/// with SwiftUI's declarative state. When SwiftUI re-evaluates our view due to
/// state changes, it calls this method to "push" those changes to UIKit.
///
/// **Performance Note**: This method should be lightweight since it can be
/// called frequently during SwiftUI's update cycles.
func updateUIViewController(_ uiViewController: PaperMarkupWrapperViewController, context: Context) {
uiViewController.setCanvasEditable(isEditable)
}
}
// MARK: - PaperMarkupWrapperViewController
///
/// A UIKit view controller that wraps and manages PaperKit's `PaperMarkupViewController`.
///
/// ## Purpose
/// This wrapper provides a clean interface between the SwiftUI representable view
/// and PaperKit's UIKit-based components. It handles all the UIKit-specific setup
/// and lifecycle management that PaperKit requires.
///
/// ## Responsibilities
/// 1. **PaperKit Integration**: Hosts `PaperMarkupViewController` as a child view controller
/// 2. **Tool Management**: Sets up and manages `PKToolPicker` for drawing tools
/// 3. **Data Persistence**: Automatically saves/loads drawings to Application Support directory
/// 4. **Delegate Handling**: Responds to PaperKit callbacks for drawing events
/// 5. **State Management**: Manages editable state and canvas clearing
///
/// ## Architecture & User Interaction Flow
/// ```
/// SwiftUI PaperMarkupView (SwiftUI declarative layer)
/// ↓ (via UIViewControllerRepresentable protocol)
/// PaperMarkupWrapperViewController (UIKit bridge layer)
/// ↓ (child view controller pattern)
/// PaperKit's PaperMarkupViewController (Apple's drawing framework)
/// ```
///
/// ## Complete User Interaction Flow
/// ```
/// 1. User taps "Clear" button in SwiftUI
/// ↓
/// 2. ContentView calls coordinator.clear()
/// ↓
/// 3. Coordinator calls wrapperViewController.clearCanvas()
/// ↓
/// 4. WrapperViewController creates new empty PaperMarkup
/// ↓
/// 5. Updates PaperKit's PaperMarkupViewController with empty canvas
///
/// OR
///
/// 1. User draws on canvas with Apple Pencil
/// ↓
/// 2. PaperKit detects drawing and calls delegate method
/// ↓
/// 3. paperMarkupViewControllerDidChangeMarkup() fired automatically
/// ↓
/// 4. Auto-save triggered in background using modern async/await
/// ↓
/// 5. Drawing saved to ~/Library/Application Support/[Bundle ID]/markup.data
/// ```
///
/// ## File Storage
/// Drawings are automatically saved to:
/// `[App Support]/[Bundle ID]/markup.data`
///
class PaperMarkupWrapperViewController: UIViewController, PaperMarkupViewController.Delegate {
// MARK: Properties
private var paperViewController: PaperMarkupViewController!
private var markupModel: PaperMarkup!
private var toolPicker: PKToolPicker!
private var isEditable: Bool
private var canvasSize: CGSize
/// **Weak Reference to Prevent Retain Cycles**
/// A weak reference to the coordinator to communicate back to the SwiftUI layer.
///
/// **Why Weak?** To prevent "retain cycles" - a memory leak where two objects
/// hold strong references to each other and can never be deallocated.
/// In our case: Coordinator → WrapperViewController → Coordinator would create a cycle.
weak var coordinator: PaperMarkupCoordinator?
// MARK: Initialization
init(canvasSize: CGSize, isEditable: Bool) {
self.isEditable = isEditable
self.canvasSize = canvasSize
super.init(nibName: nil, bundle: nil)
initializeMarkupModel(with: canvasSize)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: View Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
setupPaperMarkupViewController()
setupToolPicker()
}
/// **First Responder Concept**
/// The view controller can only become the "first responder" if it's editable.
///
/// **What's a First Responder?** In iOS, the "first responder" is the object
/// that receives user input events first (like keyboard input or tool picker actions).
/// Only the first responder can display the PencilKit tool picker.
///
/// **Why This Matters**: When `isEditable` is false, we don't want to show drawing
/// tools, so we refuse to become first responder.
override var canBecomeFirstResponder: Bool {
return isEditable
}
// MARK: Setup Methods
/// Initializes the `PaperMarkup` data model with the specified canvas size.
private func initializeMarkupModel(with canvasSize: CGSize) {
let bounds = CGRect(origin: .zero, size: canvasSize)
markupModel = PaperMarkup(bounds: bounds)
}
/// Creates, configures, and embeds the `PaperMarkupViewController`.
private func setupPaperMarkupViewController() {
paperViewController = PaperMarkupViewController(markup: markupModel, supportedFeatureSet: .latest)
// **Child View Controller Pattern**
// This is a standard UIKit pattern for embedding one view controller inside another.
// Steps: 1) Add the view to our view hierarchy, 2) Add as child controller, 3) Notify of completion
view.addSubview(paperViewController.view)
addChild(paperViewController) // Tells UIKit about the parent-child relationship
paperViewController.didMove(toParent: self) // Lets the child know it's been added
// Configure constraints to make the child view fill the parent.
paperViewController.view.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
paperViewController.view.topAnchor.constraint(equalTo: view.topAnchor),
paperViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
paperViewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
paperViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
// Set a reasonable zoom range.
paperViewController.zoomRange = 0.1...3.0
// Attempt to load any previously saved markup data.
loadMarkup()
// Set the delegate to self to handle events like data changes.
paperViewController.delegate = self
}
/// Initializes and configures the PencilKit `PKToolPicker`.
private func setupToolPicker() {
// **Become First Responder to Show Tool Picker**
// This tells iOS "this view controller wants to handle user input" and enables
// the PencilKit tool picker to be displayed for this canvas.
becomeFirstResponder()
toolPicker = PKToolPicker()
toolPicker.addObserver(paperViewController)
// **Modern PencilKit Tool Picker Management**
// Apple's recommended approach (iOS 14+) for managing tool picker visibility.
// This replaces older methods like `setVisible(_:forFirstResponder:)`.
pencilKitResponderState.activeToolPicker = toolPicker
pencilKitResponderState.toolPickerVisibility = .visible
// Add an accessory button to the tool picker for additional actions.
toolPicker.accessoryItem = UIBarButtonItem(
barButtonSystemItem: .add,
target: self,
action: #selector(addOrEditMarkupButtonPressed(_:))
)
}
// MARK: Public Control Methods
/// Clears the canvas by replacing the current markup model with a new, empty one.
func clearCanvas() {
// Re-initialize the model with a new empty drawing.
initializeMarkupModel(with: canvasSize)
markupModel?.drawing = PKDrawing()
// Update the view controller with the new, empty model.
paperViewController.markup = markupModel
}
/// Trivial implementation of undo last action
func undoLastAction() {
paperViewController.undoManager?.undo()
}
/// Sets the editable state of the canvas and shows or hides the tool picker accordingly.
func setCanvasEditable(_ editable: Bool) {
isEditable = editable
if editable {
toolPicker.setVisible(true, forFirstResponder: paperViewController)
becomeFirstResponder()
} else {
toolPicker.setVisible(false, forFirstResponder: paperViewController)
resignFirstResponder()
}
}
// MARK: Private Helpers & Actions
/// Action method for the tool picker's accessory button.
@objc private func addOrEditMarkupButtonPressed(_ button: UIBarButtonItem) {
let markupEditViewController = MarkupEditViewController(supportedFeatureSet: .latest)
markupEditViewController.delegate = paperViewController as? MarkupEditViewController.Delegate
markupEditViewController.modalPresentationStyle = .popover
markupEditViewController.popoverPresentationController?.barButtonItem = button
present(markupEditViewController, animated: true)
}
}
// MARK: - Data Persistence
///
/// Extension handling all data loading and saving operations for the markup canvas.
///
/// ## Storage Strategy
///
/// - **Automatic Location**: Saves to Application Support directory (user can't see this folder)
/// - **App Isolation**: Each app gets its own folder using Bundle ID to prevent conflicts
/// - **Auto-Save**: Triggers immediately when user draws (no "Save" button needed)
/// - **Auto-Load**: Restores previous drawing when app launches
/// - **Crash Protection**: Uses atomic writes so files are never corrupted
///
/// **File Path Example**:
/// `~/Library/Application Support/com.yourcompany.PaperKitApp/markup.data`
///
extension PaperMarkupWrapperViewController {
/// Computed property for the file URL where the markup data is saved.
/// The data is stored in the app's Application Support directory.
private var markupDataFileURL: URL {
let fileManager = FileManager.default
// Use the app's bundle identifier to create a unique folder.
let bundleID = Bundle.main.bundleIdentifier ?? "com.example.PaperKitApp"
let appSupportURL = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
let appDirectory = appSupportURL.appendingPathComponent(bundleID)
// Ensure the directory exists before attempting to write to it.
try? fileManager.createDirectory(at: appDirectory, withIntermediateDirectories: true, attributes: nil)
return appDirectory.appendingPathComponent("markup.data")
}
/// **Automatic Data Loading**
/// Loads markup data from the file system.
///
/// **When Called**: Automatically during view controller setup (setupPaperMarkupViewController)
/// **What Happens**: If a previous drawing exists, it's loaded silently in the background
/// **If No File**: Gracefully does nothing - user gets a blank canvas
func loadMarkup() {
guard FileManager.default.fileExists(atPath: markupDataFileURL.path) else {
return // No saved data exists - user gets blank canvas (normal for first launch)
}
// We use `Task` to perform file I/O on a background thread, keeping the UI responsive.
Task {
do {
// File loading happens on background thread (good for performance)
let data = try Data(contentsOf: markupDataFileURL)
let loadedMarkup = try PaperMarkup(dataRepresentation: data)
// **MainActor Requirement**
// UI updates MUST happen on the main thread. `await MainActor.run` ensures
// this code runs on the main thread.
await MainActor.run {
self.paperViewController.markup = loadedMarkup
self.markupModel = loadedMarkup
}
} catch {
print("Failed to load markup data: \(error.localizedDescription)")
}
}
}
/// **Automatic Background Saving**
/// Saves the current markup data to persistent storage.
///
/// **When Called**: Automatically triggered by paperMarkupViewControllerDidChangeMarkup()
/// whenever the user draws, erases, or modifies anything
/// **Background Operation**: File I/O happens off the main thread for smooth UI performance
/// **User Experience**: Completely invisible to user - no save dialogs or loading spinners
private func saveMarkup(_ markup: PaperMarkup) {
// **Modern Async/Await Pattern**
// This replaces older completion handler patterns for cleaner, more readable code.
Task {
do {
let data = try await markup.dataRepresentation()
// `.atomic` option ensures file integrity - either the write succeeds completely
// or fails completely (no partial/corrupted files)
try data.write(to: markupDataFileURL, options: .atomic)
} catch {
print("Failed to save markup data: \(error.localizedDescription)")
}
}
}
}
// MARK: - PaperKit Framework Callbacks (Delegate Methods)
///
/// Extension implementing the PaperMarkupViewController delegate protocol.
///
/// ## Important: These Are Automatic Framework Callbacks
/// These methods are **automatically called by the PaperKit framework** when specific
/// events occur. You don't call these methods directly - PaperKit calls them for you.
///
/// ## The Auto-Save Flow
/// ```
/// User draws on canvas
/// ↓
/// PaperKit detects change
/// ↓
/// PaperKit calls paperMarkupViewControllerDidChangeMarkup(_:)
/// ↓
/// Our code automatically saves to disk
/// ```
///
/// This creates a seamless auto-save experience without any manual save buttons.
///
extension PaperMarkupWrapperViewController {
/// **PaperKit Framework Callback**
/// Called automatically by PaperKit whenever the user modifies the drawing on the canvas.
///
/// **When This Fires**: Every time the user:
/// - Draws a new stroke with Apple Pencil or finger
/// - Erases content
/// - Adds or modifies text/shapes
/// - Performs any markup operation
///
/// **What We Do**: Immediately trigger automatic background saving to ensure
/// no user work is ever lost, even if the app crashes or is terminated.
func paperMarkupViewControllerDidChangeMarkup(_ paperMarkupViewController: PaperMarkupViewController) {
guard let currentMarkup = paperMarkupViewController.markup else { return }
saveMarkup(currentMarkup)
}
/// **PaperKit Framework Callback**
/// Called automatically when the user selects or deselects markup elements.
///
/// **When This Fires**: When the user taps to select text, shapes, or drawings
/// for editing, moving, or deleting.
///
/// **Current Implementation**: Empty - we don't need custom selection handling,
/// but PaperKit requires this method to be implemented.
func paperMarkupViewControllerDidChangeSelection(_ paperMarkupViewController: PaperMarkupViewController) {}
/// **PaperKit Framework Callback**
/// Called automatically when the user begins a new drawing stroke.
///
/// **When This Fires**: The moment the user touches the screen and starts
/// drawing with Apple Pencil, finger, or any drawing tool.
///
/// **Current Implementation**: Empty - we don't need custom drawing start handling,
/// but PaperKit requires this method to be implemented.
func paperMarkupViewControllerDidBeginDrawing(_ paperMarkupViewController: PaperMarkupViewController) {}
/// **PaperKit Framework Callback**
/// Called automatically when the visible portion of the canvas changes.
///
/// **When This Fires**: When the user:
/// - Pinches to zoom in/out
/// - Scrolls/pans around the canvas
/// - Rotates the device (if orientation changes are supported)
///
/// **Current Implementation**: Empty - we don't need custom viewport handling,
/// but PaperKit requires this method to be implemented.
func paperMarkupViewControllerDidChangeContentVisibleFrame(_ paperMarkupViewController: PaperMarkupViewController) {}
}
// MARK: - PaperMarkupCoordinator
///
/// An observable object that acts as a coordinator between the `PaperMarkupView` (SwiftUI)
/// and the `PaperMarkupWrapperViewController` (UIKit).
///
/// ## Why We Need This Coordinator
/// SwiftUI is **declarative** ("what should be displayed") while UIKit is **imperative**
/// ("do this action now"). The coordinator bridges this gap by:
///
/// 1. **Action Translation**: Converts SwiftUI button taps into UIKit method calls
/// 2. **State Management**: Maintains persistent references across SwiftUI view recreations
/// 3. **Communication Bridge**: Allows SwiftUI to trigger actions on UIKit components
///
/// ## Example Usage Flow
/// ```
/// SwiftUI Button("Clear") { coordinator.clear() }
/// ↓
/// coordinator.clear() calls wrapperViewController?.clearCanvas()
/// ↓
/// UIKit canvas is cleared immediately
/// ```
///
@Observable
class PaperMarkupCoordinator {
// A weak reference to the wrapper view controller to avoid retain cycles.
private weak var wrapperViewController: PaperMarkupWrapperViewController?
/// Connects this coordinator with its corresponding `PaperMarkupWrapperViewController`.
/// This method is called during the view controller's creation process.
/// - Parameter controller: The `PaperMarkupWrapperViewController` instance to coordinate with.
func setWrapperViewController(_ controller: PaperMarkupWrapperViewController) {
self.wrapperViewController = controller
controller.coordinator = self // Establish the back-reference.
}
/// Clears all drawings and content from the markup canvas.
func clear() {
wrapperViewController?.clearCanvas()
}
/// Trivial implementation of undo
func undo() {
wrapperViewController?.undoLastAction()
}
/// Toggles the user's ability to draw on the canvas.
/// - Parameter isEditable: A boolean indicating whether the canvas should be editable.
func setEditable(_ isEditable: Bool) {
wrapperViewController?.setCanvasEditable(isEditable)
}
}
// MARK: - SwiftUI Preview
#Preview {
NavigationView {
PaperMarkupView(coordinator: PaperMarkupCoordinator())
.edgesIgnoringSafeArea(.bottom)
.navigationTitle("Markup Canvas")
.navigationBarTitleDisplayMode(.inline)
}
}
@racheljoss
Copy link

Looks like .drawing property is now deprecated (PaperMarkupView.swift, line 286). Per: https://developer.apple.com/forums/thread/798151

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment