How do you draw with Core Graphics and UIBezierPath, and how do scale and caching affect it?

9.5.1 · L3 · Interview

Predict first

What are the point size, scale and pixel size of an image rendered at scale 3?

import UIKit

let fmt = UIGraphicsImageRendererFormat()
fmt.scale = 3
let img = UIGraphicsImageRenderer(size: CGSize(width: 10, height: 20), format: fmt).image { ctx in
    UIColor.red.setFill()
    ctx.fill(CGRect(x: 0, y: 0, width: 10, height: 20))
}
print(img.size, img.scale, img.cgImage!.width, img.cgImage!.height)
  • (10.0, 20.0) 3.0 30 60
  • (30.0, 60.0) 3.0 30 60
  • (10.0, 20.0) 1.0 10 20

Predict first

What does a path answer about geometry?

import UIKit

let p = UIBezierPath(rect: CGRect(x: 0, y: 0, width: 10, height: 10))
print(p.contains(CGPoint(x: 5, y: 5)), p.contains(CGPoint(x: 15, y: 5)))
print(UIBezierPath(roundedRect: CGRect(x: 0, y: 0, width: 100, height: 50), cornerRadius: 10).bounds)
  • true false, then (0.0, 0.0, 100.0, 50.0)
  • false true, then (0.0, 0.0, 100.0, 50.0)
  • true true, then (0.0, 0.0, 80.0, 30.0)

Mental model. Drawing is printing a picture onto a sheet. The scale is the dots per inch: a finer printer makes a sharper picture but uses far more ink, and reprinting the same picture each frame is wasted ink.

Inside draw(_:), UIKit has already set up a current graphics context (UIGraphicsGetCurrentContext(), a CGContext) sized and scaled for the view. You build shapes with UIBezierPath (a UIKit wrapper over CGPath: lines, curves, rects, rounded rects, ovals), set fill and stroke with UIColor.setFill() or the context, and fill or stroke the path. Paths answer geometry questions (bounds, contains(_:) for hit testing, even-odd or non-zero winding rules for holes) and can be used as clipping masks, as a CAShapeLayer’s path, or as a shadowPath. The context uses a flipped coordinate system in UIKit (origin top-left); raw Core Graphics APIs and Core Text use bottom-left, which is why text drawn with Core Text appears upside down until you flip the context.

To make an image rather than a view, use UIGraphicsImageRenderer (the modern API, which picks the right pixel format and scale): renderer.image { ctx in … } returns a UIImage. Its format.scale controls the pixel density: a 10×20 point image at scale 3 has a 30×60 pixel bitmap, and the memory cost grows with the square of the scale, so ask for the scale you need, not the maximum. Rendering is CPU work on whatever thread calls it; do it off the main thread for anything large and cache the result (for example a gradient or a rounded avatar image), instead of redrawing in draw(_:) on every frame. For simple shapes prefer CAShapeLayer and CAGradientLayer, which the GPU draws and which animate, to a custom draw that redraws a bitmap. Use draw(_:) when the picture really is custom and changes rarely (charts, signatures), respect the dirty rect, and do not allocate objects in it.

Points describe the logical size. The bitmap has scale × points pixels on each side.

A path knows whether it contains a point, and rounded corners do not change its bounding box.

Common traps

  • Doing heavy drawing in draw(_:) that runs often. Cache an image, or use shape layers.
  • Forgetting the scale. Drawing at 1× on a retina screen looks blurry, and asking for 3× when you do not need it wastes memory.

They might follow up with

  • Why does Core Text draw text upside down? Core Graphics’ origin is bottom-left; flip the context.
  • How do you do hit testing for a custom shape? path.contains(point).

Related questions

  • 9.1.2 What is the difference between layoutSubviews and draw(_:), and what triggers each?
  • 9.3.2 What are blending and overdraw, how do opaque views help, and what are dirty rectangles?
  • 9.6.1 How is TextKit structured, how do TextKit 1 and 2 differ, and how do UILabel, UITextView and SwiftUI Text…