Wrap A PaperKit Markup Canvas In SwiftUI
PaperKit gives apps Apple’s structured markup model and editing controllers. It is for the kind of editor where people draw with Apple Pencil, place shapes, add text boxes, annotate an image, or mark up a document without your app inventing every handle and interaction from scratch.
The first thing to get clear is that PaperKit is not a pure SwiftUI drawing view. On iOS, the interactive canvas is PaperMarkupViewController, a UIKit view controller. SwiftUI integration means wrapping that controller, keeping its data model in your app state, and letting UIKit own the editing surface.
The code below targets iOS 26 and newer, and matches the public PaperKit interface in the Xcode 26.5 SDK. It sticks to the types this editor needs: PaperMarkup, PaperMarkupViewController, MarkupEditViewController, FeatureSet, and the PaperMarkupViewController.Delegate callbacks.
Start with the model
PaperMarkup is the document model. It can be created with bounds, loaded from a data representation, serialized back to data, rendered into a CGContext asynchronously, and inspected for the features its content uses.
For a simple document editor, keep the markup in an observable store:
import Foundation
import PaperKit
import SwiftUI
@available(iOS 26.0, *)
@MainActor
final class MarkupStore: ObservableObject {
@Published var markup: PaperMarkup?
@Published var loadError: String?
private let fileURL: URL
init(fileURL: URL) {
self.fileURL = fileURL
}
func load(bounds: CGRect, supportedFeatureSet: FeatureSet) {
do {
let data = try Data(contentsOf: fileURL)
var loadedMarkup = try PaperMarkup(dataRepresentation: data)
if !loadedMarkup.featureSet.isSubset(of: supportedFeatureSet) {
loadedMarkup.removeContentUnsupported(by: supportedFeatureSet)
}
markup = loadedMarkup
loadError = nil
} catch let error as CocoaError where error.code == .fileReadNoSuchFile {
markup = PaperMarkup(bounds: bounds)
loadError = nil
} catch {
markup = PaperMarkup(bounds: bounds)
loadError = error.localizedDescription
}
}
func save(_ markup: PaperMarkup?) {
guard let markup else { return }
Task {
do {
let data = try await markup.dataRepresentation()
try data.write(to: fileURL, options: .atomic)
} catch {
loadError = error.localizedDescription
}
}
}
}
Production code should coalesce these saves. PaperMarkupViewController can report frequent changes while the person is drawing, and unstructured save tasks can overlap. Debounce the callback, serialize writes through one saving actor, or cancel and replace the previous pending save so an older write cannot finish after a newer one.
That removeContentUnsupported(by:) call deserves a pause. It is a destructive cleanup step. Use it when importing content into an editor that intentionally does not support everything. If the content is from a newer OS and you want forward compatibility instead, a better path is to keep the original data, show a pre-rendered thumbnail, and ask the user to edit on a newer OS.
The file reads and writes here are intentionally small. For a large document format, move disk I/O out of the main actor and keep the PaperMarkup state update on the main actor.
Pick a feature set
FeatureSet.latest is the easiest starting point because it opts into everything the current framework can do. Production editors often need a narrower contract. A homework annotator might support drawing, arrows, rectangles, and text. A screenshot redactor might not want stickers, loupes, or links. A form-filling app might disable freeform drawing entirely.
Build that policy once and pass it everywhere PaperKit asks what your app supports:
import PaperKit
import PencilKit
@available(iOS 26.0, *)
extension FeatureSet {
static var annotationEditor: FeatureSet {
var set = FeatureSet.latest
set.remove(.stickers)
set.remove(.loupes)
set.remove(.links)
set.shapes = [
.rectangle,
.roundedRectangle,
.ellipse,
.line,
.arrowShape
]
set.inks = [
.pen,
.pencil,
.marker
]
set.lineMarkerPositions = [.plain, .single]
set.colorMaximumLinearExposure = 1
return set
}
}
The important part is consistency. Use the same feature set for the markup controller and any insertion UI you present. Otherwise the editor can advertise tools that the canvas does not support, or accept markup your app does not know how to preserve.
Wrap the markup controller
The SwiftUI wrapper is a UIViewControllerRepresentable. It creates a PaperMarkupViewController, sets the delegate, and copies changes back into SwiftUI state:
import SwiftUI
import PaperKit
@available(iOS 26.0, *)
struct PaperMarkupCanvas: UIViewControllerRepresentable {
@Binding var markup: PaperMarkup?
let featureSet: FeatureSet
var onChange: (PaperMarkup) -> Void
func makeCoordinator() -> Coordinator {
Coordinator(parent: self)
}
func makeUIViewController(context: Context) -> PaperMarkupViewController {
let controller = PaperMarkupViewController(
markup: markup,
supportedFeatureSet: featureSet)
controller.delegate = context.coordinator
controller.isEditable = true
controller.zoomRange = 0.5...4
return controller
}
func updateUIViewController(
_ controller: PaperMarkupViewController,
context: Context
) {
context.coordinator.parent = self
if controller.markup != markup {
controller.markup = markup
}
}
final class Coordinator: PaperMarkupViewController.Delegate {
var parent: PaperMarkupCanvas
init(parent: PaperMarkupCanvas) {
self.parent = parent
}
func paperMarkupViewControllerDidChangeMarkup(
_ controller: PaperMarkupViewController
) {
guard let markup = controller.markup else { return }
parent.markup = markup
parent.onChange(markup)
}
}
}
The delegate protocol also includes callbacks for selection changes, drawing start, and visible-frame changes. Add those when your app needs custom inspector panels, autosave timing, or scroll position restoration. For a first pass, saving on markup changes is enough.
Put it in a SwiftUI screen
Use GeometryReader to create a new markup model with the size of the available canvas. Load once, then let the representable and store keep each other in sync:
import SwiftUI
import PaperKit
@available(iOS 26.0, *)
struct MarkupDocumentView: View {
@StateObject private var store: MarkupStore
private let featureSet = FeatureSet.annotationEditor
init(fileURL: URL) {
_store = StateObject(wrappedValue: MarkupStore(fileURL: fileURL))
}
var body: some View {
GeometryReader { proxy in
PaperMarkupCanvas(
markup: $store.markup,
featureSet: featureSet,
onChange: store.save)
.task(id: proxy.size) {
if store.markup == nil && proxy.size != .zero {
store.load(
bounds: CGRect(origin: .zero, size: proxy.size),
supportedFeatureSet: featureSet)
}
}
}
.navigationTitle("Markup")
.navigationBarTitleDisplayMode(.inline)
}
}
This is still a UIKit editor wrapped in SwiftUI. That is a good thing to be honest about in your architecture. SwiftUI owns navigation, document selection, inspector state, and persistence. PaperKit owns the editing canvas.
Add insertion tools from UIKit
The wrapper above lets people draw and edit the canvas, but PaperKit’s insertion menu is also a UIKit controller. On iOS, the public controller for that is MarkupEditViewController. Present it from the UIKit owner that has access to the PaperMarkupViewController, and set its delegate to the markup controller:
@available(iOS 26.0, *)
@MainActor
func presentInsertionMenu(
from button: UIBarButtonItem,
paperViewController: PaperMarkupViewController,
featureSet: FeatureSet,
presenter: UIViewController
) {
let editController = MarkupEditViewController(
supportedFeatureSet: featureSet)
editController.delegate = paperViewController
editController.modalPresentationStyle = .popover
editController.popoverPresentationController?.barButtonItem = button
presenter.present(editController, animated: true)
}
That snippet is intentionally UIKit-shaped. You can wrap this host controller in SwiftUI too, but the insertion controller still wants a UIKit delegate and popover anchor. Trying to hide that fact usually makes the code harder to reason about.
If you use PencilKit’s tool picker alongside PaperKit, configure it from the UIKit side and register the paper controller as an observer. Keep the feature set aligned with the tools you expose. PaperKit and PencilKit complement each other: PencilKit supplies the drawing tools, while PaperKit adds structured markup elements like shapes, images, and text boxes.
Save more than the latest data
Saving PaperMarkup.dataRepresentation() is the core persistence step. For a production document format, I would also store:
- A schema version for your document wrapper.
- A thumbnail rendered with
await PaperMarkup.draw(in:frame:options:). - The feature set your editor used when saving.
- The original data if you open markup from a newer OS and cannot safely edit it.
That forward-compatibility path is not just paranoia. PaperKit has a content version, and PaperMarkup(dataRepresentation:) can fail when the data is malformed, incorrect, or too new for the framework on the current OS. If editing is unsafe, show the thumbnail and keep the original file intact.
The pattern
The practical shape is small:
- Create or load
PaperMarkup. - Choose one
FeatureSetthat matches your product. - Wrap
PaperMarkupViewControllerwithUIViewControllerRepresentable. - Use the delegate to save
PaperMarkup.dataRepresentation(). - Present
MarkupEditViewControllerfrom a UIKit owner when you need insertion tools.
That gives SwiftUI the app shell and PaperKit the editing surface, which is exactly where each framework is strongest.
Further reading:
- Apple documentation: https://developer.apple.com/documentation/paperkit/getting-started-with-paperkit
- WWDC25: Meet PaperKit: https://developer.apple.com/videos/play/wwdc2025/285/
- PaperKit FeatureSet: https://developer.apple.com/documentation/paperkit/featureset
- PaperMarkupViewController initializer: https://developer.apple.com/documentation/paperkit/papermarkupviewcontroller/init%28markup%3Asupportedfeatureset%3A%29