How do the non-zero and even-odd fill rules decide what is inside a path, and what does contains(_:) ignore?
Predict first
Is the centre of a nested square filled under each rule, and does a line contain a point on it?
import UIKit
let outer = CGRect(x: 0, y: 0, width: 100, height: 100), inner = CGRect(x: 25, y: 25, width: 50, height: 50)
let center = CGPoint(x: 50, y: 50)
let path = UIBezierPath(rect: outer); path.append(UIBezierPath(rect: inner))
print(path.contains(center))
path.usesEvenOddFillRule = true; print(path.contains(center))
let hole = UIBezierPath(rect: outer); hole.append(UIBezierPath(rect: inner).reversing())
print(hole.contains(center))
let line = UIBezierPath(); line.move(to: .zero); line.addLine(to: CGPoint(x: 100, y: 0)); line.lineWidth = 10
print(line.contains(CGPoint(x: 50, y: 2)), line.bounds.height, line.cgPath.copy(strokingWithWidth: 10, lineCap: .butt, lineJoin: .miter, miterLimit: 10).boundingBox.height)
- true, false, false, then false 0.0 10.0
- false, true, true, then true 10.0 10.0
- true, true, false, then false 0.0 0.0
Mental model. Non-zero counts how many times you wind around a point; even-odd just counts fences between the point and the outside.
For a path with overlapping or nested sub-paths, “inside” needs a rule. Non-zero winding (the default) draws a ray from the point and adds one for each path crossing it counter-clockwise and subtracts one for clockwise; the point is inside if the total is not zero. Even-odd just counts crossings: odd means inside. For a square with a smaller square inside it, both drawn the same direction, non-zero says the inner square is filled too (winding 2) while even-odd leaves a hole (two crossings). If the inner square is drawn in the opposite direction, non-zero also gives a hole (1 − 1 = 0), which is how you cut a hole without switching rules: reversing() the inner path. Set it with usesEvenOddFillRule on a UIBezierPath, or fillRule = .evenOdd on a CAShapeLayer, or by passing the rule to CGPath.contains(_:using:).
Two practical points. contains(_:) tests the filled area under the active rule and ignores stroke width, so a straight line (zero area) never contains a point, even one right on it; to hit-test a thick line, build the stroked outline with copy(strokingWithWidth:lineCap:lineJoin:miterLimit:) and test that, or test distance yourself. And a path’s bounds excludes the stroke too: the stroked outline of a 100-point line of width 10 is 10 points tall, though the path’s bounds are 0 tall. Use these for custom hit regions, ring or donut shapes, and masks with holes.
Non-zero fills same-direction nesting; even-odd and a reversed inner path make a hole; a line has no area.
Common traps
- Hit-testing a stroked line with
contains. The path has no area; test the stroked outline. - Making a hole with two same-direction rectangles under non-zero. The hole stays filled; reverse the inner path or use even-odd.
They might follow up with
- How do you draw a donut? Two circles with even-odd, or an outer path and a reversed inner one.
- Does
boundsinclude the line width? No; the stroked outline does.
Related questions
- 9.5.1 How do you draw with Core Graphics and UIBezierPath, and how do scale and caching affect it?
- 7.1.1 How does UIKit decide which view receives a touch, and why can a subview outside its parent’s bounds not be…
- 9.11.1 In which order do CGAffineTransform operations apply, and what do scaledBy, rotated and translatedBy do…
← In which order do CGAffineTransform operations apply, and what do… How do you measure text correctly: font metrics, boundingRect options… →