So, I guess you want something like this:

I'm turning the completely “erased” lines dashed instead of removing them entirely.
If your deployment target is at least iOS 16, then you can use the lineSubtracting
method of CGPath
to do the “heavy lifting”.
Apple still hasn't provided real documentation of this method on the web site, but the header file describes it as follows:
Returns a new path with a line from this path that does not overlap the filled region of the given path.
- Parameters:
- other: The path to subtract.
- rule: The rule for determining which areas to treat as the interior of
other
.
Defaults to the CGPathFillRule.winding
rule if not specified.
- Returns: A new path.
The line of the resulting path is the line of this path that does not overlap the filled region of other
.
Intersected subpaths that are clipped create open subpaths. Closed subpaths that do not intersect other
remain closed.
So, here's the strategy:
- Create a
CGPath
for one of your straight line segments.
- Create a
CGPath
of the user's erase gesture.
- Stroke the erase path.
- Use
lineSubtracting
on the straight line segment path, passing the stroked erase path, to get a path containing just the part of the straight line segment that is not covered by the eraser.
- If the path returned by
lineSubtracting
is empty, the straight line has been completely erased.
- Repeat for each straight line segment.
Let's try it out. First, I'll write a model type to store both an original path and the part of that path that remains after erasing:
struct ErasablePath {
var original: Path
var remaining: Path
}
Let's add a couple of extra initializers, and a method that updates the remaining
path by subtracting a (stroked) eraser path:
extension ErasablePath {
init(_ path: Path) {
self.init(original: path, remaining: path)
}
init(start: CGPoint, end: CGPoint) {
self.init(Path {
$0.move(to: start)
$0.addLine(to: end)
})
}
func erasing(_ eraserPath: CGPath) -> Self {
return Self(
original: original,
remaining: Path(remaining.cgPath.lineSubtracting(eraserPath))
)
}
}
I'll use the following function to turn an array of points into an array of ErasablePath
s:
func makeErasableLines(points: [CGPoint]) -> [ErasablePath] {
guard let first = points.first, let last = points.dropFirst().last else {
return []
}
return zip(points, points.dropFirst()).map {
ErasablePath(start: $0, end: $1)
} + [ErasablePath(start: last, end: first)]
}
Here is the complete data model for the toy app:
struct Model {
var erasables: [ErasablePath] = makeErasableLines(points: [
CGPoint(x: 50, y: 100),
CGPoint(x: 300, y: 100),
CGPoint(x: 300, y: 400),
CGPoint(x: 175, y: 400),
CGPoint(x: 175, y: 250),
CGPoint(x: 50, y: 250),
])
var eraserPath: Path = Path()
var strokedEraserPath: Path = Path()
var isErasing: Bool = false
let lineWidth: CGFloat = 44
}
To update the model as the user interacts with the app, I'll need methods to respond to the user starting, moving, and ending a touch, and a way to reset the data model:
extension Model {
mutating func startErasing(at point: CGPoint) {
eraserPath.move(to: point)
isErasing = true
}
mutating func continueErasing(to point: CGPoint) {
eraserPath.addLine(to: point)
strokedEraserPath = eraserPath.strokedPath(.init(
lineWidth: 44,
lineCap: .round,
lineJoin: .round
))
let cgEraserPath = strokedEraserPath.cgPath
erasables = erasables
.map { $0.erasing(cgEraserPath) }
}
mutating func endErasing() {
isErasing = false
}
mutating func reset() {
self = .init()
}
}
We need a view that draws the erasable paths and the eraser path. I'll draw each original erasable path in green, and draw it dashed if it's been fully erased. I'll draw the remaining (unerased) part of each erasable path in red. And I'll draw the stroked eraser path in semitransparent purple.
struct DrawingView: View {
@Binding var model: Model
var body: some View {
Canvas { gc, size in
for erasable in model.erasables {
gc.stroke(
erasable.original,
with: .color(.green),
style: .init(
lineWidth: 2,
lineCap: .round,
lineJoin: .round,
miterLimit: 1,
dash: erasable.remaining.isEmpty ? [8, 8] : [],
dashPhase: 4
)
)
}
for erasable in model.erasables {
gc.stroke(
erasable.remaining,
with: .color(.red),
lineWidth: 2
)
}
gc.fill(
model.strokedEraserPath,
with: .color(.purple.opacity(0.5))
)
}
}
}
In my ContentView
, I'll add a DragGesture
on the drawing view, and also show a reset button:
struct ContentView: View {
@Binding var model: Model
var body: some View {
VStack {
DrawingView(model: $model)
.gesture(eraseGesture)
Button("Reset") { model.reset() }
.padding()
}
}
var eraseGesture: some Gesture {
DragGesture(minimumDistance: 0, coordinateSpace: .local)
.onChanged { drag in
if model.isErasing {
model.continueErasing(to: drag.location)
} else {
model.startErasing(at: drag.location)
}
}
.onEnded { drag in
model.endErasing()
}
}
}
That's the code I used to generate the animation at the top of the answer. But I confess that it was a rigged demo. The lineSubtracting
method is a little buggy and I was careful to avoid triggering the bug. Here's the bug:

If an ErasablePath
is a horizontal line segment, and the eraser path starts below that segment, then lineSubtracting
removes the entire erasable path, even if the eraser path and the line segment have no overlap!
To work around the bug, I insert the following init
method into Model
:
struct Model {
... existing code ...
init() {
// lineSubtracting has a bug (still present in iOS 17.0 beta 1):
// If the receiver is a horizontal line, and the argument (this eraserPath) starts below that line, the entire receiver is removed, even if the argument doesn't intersect the receiver at all.
// To work around the bug, I put a zero-length segment at the beginning of eraserPath way above the actual touchable area.
startErasing(at: .init(x: -1000, y: -1000))
continueErasing(to: .init(x: -1000, y: -1000))
endErasing()
}
}
The eraser path always starts above the erasable paths, so it no longer triggers the bug:
