Importing Bézier path data into PencilKit
Convert existing Bézier-based stroke data into PencilKit drawing strokes.
Overview
If your app handles drawing data as Bézier paths, such as when you import an existing file format, interface with a third-party library, or store drawing data in your own data model, you can convert those Bézier paths directly into PencilKit drawing strokes and adopt PencilKit as your rendering engine without discarding your existing data.
PencilKit uses a stroke format optimized for Apple Pencil input, represented by PKStroke, which differs from the Bézier path format that Core Graphics and other frameworks use. Because the two formats represent curves differently, the conversion is an approximation. After you convert your stroke data, review the results to confirm the strokes look as you expect.
Convert a Bézier path to a stroke
To convert a Bézier path, initialize a PKStrokePath from a CGPath using init(bezierPath:creationDate:pointProvider:). Because a Bézier path describes only the shape of a curve and not the per-point properties PencilKit uses for rendering, such as pressure, opacity, and size, you supply those values through the pointProvider closure, which the system calls once for each point in the resulting path.
To call the initializer, you supply two values alongside the Bézier path: a creationDate for the stroke, and a pointProvider closure that returns rendering properties for each point. The creationDate is the start time of the stroke. Pass a timestamp from your source data if one exists, or use Date() for the current time.
The number of points PencilKit derives from your Bézier path may differ from the number of control points in the original. Use index and pointCount to calculate values that vary along the stroke rather than mapping points one-to-one from your source data.
The initializer calls pointProvider once for each derived point, passing a PKStrokePath.ConvertedBezierPoint with the following values:
location: The position of the derived B-spline control point, which you pass directly to PKStrokePoint.
index and pointCount:
indexis the zero-based position of the control point in the derived path, andpointCountis the total number of control points. Divide one by the other to calculate a progress value between 0 and 1 for properties that vary along the stroke.bezierSegmentIndex: The index of the original Bézier segment that this derived control point maps to. Use this value if your source data stores per-segment properties like color or width.
PencilKit sets location from the Bézier path geometry, but you define all other properties — such as size, opacity, and force — in your pointProvider closure. For the following properties, consider whether your source data includes values to use rather than a fixed default:
timeOffset: The time in seconds from the stroke’s
creationDateto this derived control point. The example below distributes time evenly along the stroke based on index position, giving earlier points smaller offsets and later points larger ones. If your source data includes per-point or per-segment timestamps, use those elapsed times instead.azimuth and altitude: These properties describe the orientation of an Apple Pencil. For imported data that doesn’t include pencil orientation, use reasonable values instead. For example, an altitude of
.pi / 4matches how most people naturally hold a pencil. Because azimuth only affects rendering once the pencil is tilted, choose a value that fits your ink and app rather than relying on a single default.
The following example shows a complete conversion from a CGPath to a PKStroke, using index and pointCount to calculate timeOffset and applying uniform values for all other properties:
func makeStroke(from bezierPath: CGPath, ink: PKInk) -> PKStroke {
let path = PKStrokePath(
bezierPath: bezierPath,
creationDate: Date(),
pointProvider: { convertedPoint in
let progress = CGFloat(convertedPoint.index) / CGFloat(convertedPoint.pointCount)
return PKStrokePoint(
location: convertedPoint.location,
timeOffset: 0.5 * progress,
size: CGSize(width: 3.0, height: 3.0),
opacity: 1.0,
force: 1.0,
azimuth: .pi,
altitude: .pi / 4,
secondaryScale: 1.0,
threshold: 0.0
)
}
)
return PKStroke(ink: ink, path: path)
}Save and load PencilKit strokes in a Bézier file format
If you want to keep Bézier paths as your file format after adopting PencilKit, you can export strokes back to Bézier paths for saving and reload them with full fidelity. Use bezierRepresentation to export a stroke path to a CGPath for saving, then init(bezierPath:creationDate:pointProvider:) to load it back.
A Bézier path stores only the shape of the curve and doesn’t include properties like size, opacity, and force. When exporting a PencilKit stroke, save those properties separately for each point so you can reconstruct the full stroke when loading back.
The following example shows how to save these additional properties alongside the Bézier path:
struct SavedPoint: Codable {
let timeOffset: TimeInterval
let size: CGSize
let opacity: CGFloat
let force: CGFloat
let azimuth: CGFloat
let altitude: CGFloat
let secondaryScale: CGFloat
let threshold: CGFloat
init(_ point: PKStrokePoint) {
timeOffset = point.timeOffset
size = point.size
opacity = point.opacity
force = point.force
azimuth = point.azimuth
altitude = point.altitude
secondaryScale = point.secondaryScale
threshold = point.threshold
}
}
let bezierPath = stroke.path.bezierRepresentation
let savedPoints = stroke.path.map { SavedPoint($0) }When loading back a path exported with bezierRepresentation, the number of control points is guaranteed to match the original stroke’s point count — so you can look up each point’s saved data by index:
let restoredPath = PKStrokePath(
bezierPath: bezierPath,
creationDate: Date(),
pointProvider: { convertedPoint in
let saved = savedPoints[convertedPoint.index]
return PKStrokePoint(
location: convertedPoint.location,
timeOffset: saved.timeOffset,
size: saved.size,
opacity: saved.opacity,
force: saved.force,
azimuth: saved.azimuth,
altitude: saved.altitude,
secondaryScale: saved.secondaryScale,
threshold: saved.threshold
)
}
)Add the converted strokes to a drawing
PKDrawing holds all the strokes that appear in a canvas. After converting your strokes, set the strokes property on a new drawing and assign it to your PKCanvasView. This replaces any existing content in the canvas with your imported strokes. The following example converts a collection of legacy paths to strokes and assigns them to a canvas:
let strokes = legacyDocument.paths.map { path in
makeStroke(from: path.cgPath, ink: PKInk(inkType: .pen, color: path.color))
}
var drawing = PKDrawing()
drawing.strokes = strokes
canvasView.drawing = drawing