Here is my requirement. Basically it is an eraser concept. I have polygon with n
sides. (x,y)
of all lines are stored in a model. To erase any part in the polygon, user can draw on the polygon over the lines and if the line comes completely under the coverage of the free hand drawing, the particular lines must be highlighted and later it could be erased.
Context drawing is used to draw both polygon and eraser. Based on the line data in model, the polygon is drawn. When user draws in eraser mode, it is stroked with larger line width in light blue colour. When the drawing has a complete coverage over any line in polygon, the line could be highlighted in dark blue indicating selection.
Problem is, I couldn't able to determine if the line has complete coverage over the eraser. Since I have only start and end point of a line, it gets difficult to determine it. What could be reliable way to identify the lines as user draws in eraser mode?
EDIT Trying to provide answer for the questions in comment
For the first question, the bottom straight line has to be identified and deleted since it is covered completely. To answer second situation, none of the red lines will be selected/identified. To answer third, all lines that comes completely under the coverage of blue stroke must be selected - highlighted in green.
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 theCGPathFillRule.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:
CGPath
for one of your straight line segments.CGPath
of the user's erase gesture.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.lineSubtracting
is empty, the straight line has been completely erased.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: