Introduction to Core Graphics in iOS Development

Core Graphics, also known as Quartz 2D, is a low-level drawing engine that underpins all rendering in iOS and macOS. Unlike higher-level frameworks like UIKit or SwiftUI, Core Graphics gives you pixel‑perfect control over every visual element rendered on screen. When you need custom shapes, complex gradients, image masking, or performant off‑screen drawing, Core Graphics is the tool to reach for. Mastering this framework allows you to create unique UI components, data visualizations, game assets, and decorative elements that set your app apart.

Drawing with Core Graphics is resolution‑independent and leverages the GPU when possible. The framework uses a painter’s model: each drawing operation paints over the previous one, following the stacking order. This makes it intuitive for creating layered visuals. You’ll find Core Graphics used extensively in system‑level components such as UILabel, UIImageView, and MKMapView — understanding its internals helps you debug rendering issues and write more efficient custom views.

Core Graphics vs. UIKit Drawing

UIKit provides convenience methods like UIBezierPath and UIColor for basic drawing, but these are built on top of Core Graphics. When you call draw(_:) in a UIView, UIKit sets up the graphics context and calls Core Graphics routines under the hood. Directly using Core Graphics gives you:

  • Access to low‑level path construction (move, line, curve, arc, path‑closing).
  • Full control over stroke and fill parameters (line dash, line cap, line join, miter limit, alpha blending).
  • Creation of gradients, shadows, and transparency layers.
  • Drawing of images and text with advanced compositing options.
  • Off‑screen rendering with UIGraphicsImageRenderer or CGBitmapContext.

For most standard shapes, UIBezierPath is sufficient. But when you need to build a path incrementally from data, combine multiple paths into a composite shape, or apply custom clipping masks, diving into CGMutablePath and CGContext methods becomes necessary.

Setting Up the Graphics Context

All Core Graphics drawing requires a current graphics context. In a UIView subclass, the context is automatically provided inside the draw(_:) method. Retrieve it with UIGraphicsGetCurrentContext(). Outside of draw(_:) — for example, when creating an image in the background — you create a bitmap context using UIGraphicsImageRenderer (iOS 10+) or CGContext directly.

Example: Basic Drawing in a Custom View

class CustomView: UIView {
    override func draw(_ rect: CGRect) {
        guard let context = UIGraphicsGetCurrentContext() else { return }
        // Drawing commands go here
    }
}

Remember that draw(_:) is called when the view first appears and whenever the system determines the view needs a refresh (e.g., after setNeedsDisplay()). For performance, avoid creating temporary objects inside draw(_:) that could be reused.

Coordinate System and Transformations

Core Graphics uses a Cartesian coordinate system with the origin at the bottom‑left corner of the drawing area. This differs from UIKit’s top‑left origin. However, UIKit automatically applies a flip transform to the context inside draw(_:) so you can treat the origin as top‑left for most operations. When working with CGContext directly in a bitmap or PDF context, you must manage the coordinate system yourself.

You can apply affine transformations to the context using context.translateBy(x:y:), context.scaleBy(x:y:), and context.rotate(by:). These transforms affect all subsequent drawing commands. They are essential for creating rotating indicators, scaling charts, or positioning elements relative to a local origin.

Drawing Paths and Shapes

Paths are the foundation of any custom drawing. A path consists of a sequence of lines, arcs, curves, and subpaths. You can build a path using CGPath (immutable) or CGMutablePath (mutable). For convenience, many developers use UIBezierPath which internally wraps a CGPath and provides a more Swift‑friendly API.

Constructing a Path

To draw a star, for example, you would:

  1. Move to a starting point.
  2. Add lines to each vertex.
  3. Close the path.
  4. Stroke and/or fill.

Example: Creating a five‑pointed star

override func draw(_ rect: CGRect) {
    guard let context = UIGraphicsGetCurrentContext() else { return }
    // Star points calculated from rect center and radius
    let center = CGPoint(x: rect.midX, y: rect.midY)
    let outerRadius: CGFloat = 80
    let innerRadius: CGFloat = 30
    let points = 5
    let path = CGMutablePath()
    let angle = (Double.pi * 2) / Double(points * 2)
    var startPoint: CGPoint = .zero
    for i in 0..<(points * 2) {
        let radius = (i % 2 == 0) ? outerRadius : innerRadius
        let x = center.x + radius * CGFloat(cos(angle * Double(i) - .pi / 2))
        let y = center.y + radius * CGFloat(sin(angle * Double(i) - .pi / 2))
        if i == 0 {
            path.move(to: CGPoint(x: x, y: y))
            startPoint = CGPoint(x: x, y: y)
        } else {
            path.addLine(to: CGPoint(x: x, y: y))
        }
    }
    path.closeSubpath()
    context.addPath(path)
    context.setFillColor(UIColor.systemYellow.cgColor)
    context.setStrokeColor(UIColor.orange.cgColor)
    context.setLineWidth(4)
    context.drawPath(using: .fillStroke)
}

Using UIBezierPath

UIBezierPath simplifies path creation and supports shapes like rounded rectangles, arcs, and curves. For complex paths, you can use its instance methods: move(to:), addLine(to:), addArc(withCenter:radius:startAngle:endAngle:clockwise:), addCurve(to:controlPoint1:controlPoint2:), addQuadCurve(to:controlPoint:). After building the path, set its fill and stroke parameters, then call fill() and stroke() — these methods automatically fetch the current context.

Colors, Gradients, and Shadows

Core Graphics works with low‑level CGColor objects, not UIColor directly. Convert a UIColor to its cgColor property before passing to context functions.

Gradients

Two types of gradients are supported: linear and radial. A gradient requires a CGGradient object created from a color space (CGColorSpace) and an array of CGColors with optional location values.

Example: Linear gradient filling a rectangle

let colors = [UIColor.systemBlue.cgColor, UIColor.systemGreen.cgColor] as CFArray
let colorSpace = CGColorSpaceCreateDeviceRGB()
let gradient = CGGradient(colorsSpace: colorSpace, colors: colors, locations: [0.0, 1.0])!
context.drawLinearGradient(gradient,
    start: CGPoint(x: 0, y: 0),
    end: CGPoint(x: rect.width, y: rect.height),
    options: [])

For radial gradients, use drawRadialGradient(_:startCenter:startRadius:endCenter:endRadius:options:). Gradients are often clipped to a path by first saving the context state, adding the path as a clipping mask, drawing the gradient, then restoring state.

Shadows

Shadows add depth. Use context.setShadow(offset:blur:color:) before drawing. The offset is a CGSize, blur a CGFloat, and color an optional CGColor.

context.setShadow(offset: CGSize(width: 3, height: 3), blur: 5, color: UIColor.black.withAlphaComponent(0.3).cgColor)
// draw your shape
context.setShadow(offset: .zero, blur: 0, color: nil) // reset

Drawing Images and Text

Images can be drawn directly using context.draw(_:in:) or through UIImage.draw(in:) (which internally uses Core Graphics). For advanced compositing, you can set the blend mode with context.setBlendMode(_:) — useful for masking effects or overlays.

Text drawing in Core Graphics is low‑level and requires Core Text or NSString drawing methods. UIKit’s draw(in:withAttributes:) is simpler, but if you need full control for a custom renderer, use CTLine or NSAttributedString with a CGContext.

Example: Drawing an image with a circular clip

context.saveGState()
let circlePath = UIBezierPath(ovalIn: rect)
circlePath.addClip()
image.draw(in: rect)
context.restoreGState()

Performance Considerations

Core Graphics drawing happens on the CPU and can become expensive if used excessively in a scroll‑heavy view. To maintain smooth animations:

  • Minimize redraws: Only call setNeedsDisplay() when content actually changes.
  • Use off‑screen rendering: Pre‑render static content into a UIImage using UIGraphicsImageRenderer, then display that image.
  • Avoid creating objects inside draw(_:): Cache paths, gradients, and colors as class properties.
  • Use draw only for custom drawing: If you can compose your visual using standard views and layers, it’s often more performant.
  • Leverage CALayer drawing: Override draw(in:) in a custom CALayer subclass instead of UIView.draw(_:) for fine‑grained layer control.

For animations, avoid redrawing every frame if the path doesn’t change. Instead, use CAShapeLayer which renders vector paths on the GPU and supports animating properties like path, fillColor, and strokeEnd without redraw overhead.

Integrating Core Graphics with UIKit and SwiftUI

In UIKit, custom drawing is done in UIView subclasses. In SwiftUI, you can use Canvas (iOS 15+) or wrap a UIView inside UIViewRepresentable. The Canvas view provides a GraphicsContext that mirrors Core Graphics functionality with a more Swift‑like syntax. However, for complex paths and performance‑critical drawing, wrapping a Core Graphics view remains a viable option.

Using Core Graphics in SwiftUI

struct CoreGraphicsView: UIViewRepresentable {
    func makeUIView(context: Context) -> UIView {
        return CustomView()
    }
    func updateUIView(_ uiView: UIView, context: Context) {}
}

Then use CoreGraphicsView().frame(width: 200, height: 200) inside your SwiftUI layout.

Common Pitfalls and Best Practices

  • Missing context: Always check that UIGraphicsGetCurrentContext() is not nil, especially when drawing outside of draw(_:).
  • Coordinate flip: When drawing text or images in a bitmap context, remember to flip the coordinate system using context.scaleBy(x: 1, y: -1) and context.translateBy(x: 0, y: -height) unless you use UIKit convenience methods.
  • State management: Use context.saveGState() and context.restoreGState() to isolate transforms, clipping, and shadow settings.
  • Retina awareness: Views are automatically scaled on Retina displays, but when using bitmap contexts, set the scale factor explicitly: UIGraphicsImageRenderer(bounds: rect, format: UIGraphicsImageRendererFormat()).
  • Thread safety: All drawing must happen on the main thread unless you are creating off‑screen images in the background (using UIGraphicsImageRenderer is thread‑safe).

Real‑World Use Cases

  • Custom charts and graphs: Draw axes, gridlines, bars, or pie slices with precise colors and gradients.
  • Watermarks and stamps: Combine text, images, and transparent overlays.
  • Animated loading indicators: Use CAShapeLayer with Core Graphics paths for morphing shapes.
  • Drawing apps: Capture touch points and build paths dynamically, rendering each stroke with variable width and opacity.
  • PDF generation: Create reports or invoices by drawing onto a PDF context.

Conclusion

Core Graphics is a mature, powerful framework that gives iOS developers direct access to the rendering pipeline. While it has a steeper learning curve than UIKit’s high‑level abstractions, the payoff is complete creative freedom and often better performance for complex vector graphics. By understanding contexts, paths, colors, gradients, and transformations, you can build visually stunning custom interfaces that stand out. Practice by drawing simple shapes, then progress to layered illustrations and interactive graphics. For further reading, refer to Apple’s Core Graphics documentation and the Quartz 2D Programming Guide. Also explore open‑source projects on GitHub that use Core Graphics for custom charting libraries – they provide excellent learning examples.