1 minute read

Custom drawing with Core Graphics in Swift

Core Graphics gives an iOS app a direct way to draw lines, shapes, gradients, images and text. UIKit components are often enough for standard screens, yet custom drawing becomes useful when a design includes a unique chart, handwritten mark, game element, diagram or loading indicator. Instead of arranging many views, you can describe the visual result with a small set of drawing commands.

The framework can appear complex because it works with paths, contexts, transforms, colours and coordinate spaces. The main idea is simpler: obtain a graphics context, define the geometry, configure the drawing state, and render the result. Once that cycle is clear, custom graphics become easier to control, test and reuse in an iOS project.

Understanding the drawing context

A CGContext is the environment where Core Graphics performs its work. It stores the current fill colour, stroke colour, line width, clipping area, transformation matrix and other settings. During a view’s drawing pass, UIKit normally provides this context for you.

For a reusable custom view, override draw(_:) and use the context supplied by UIKit:

final class BadgeView: UIView {
    override func draw(_ rect: CGRect) {
        guard let context = UIGraphicsGetCurrentContext() else { return }

        context.setFillColor(UIColor.systemBlue.cgColor)
        context.fillEllipse(in: rect.insetBy(dx: 8, dy: 8))
    }
}

The context is stateful. If you change the fill colour or line width, that setting remains active until you change it again. Saving and restoring the state is a reliable way to prevent one drawing operation from affecting another:

context.saveGState()
context.setLineWidth(4)
context.rotate(by: .pi / 8)
// Draw transformed content here
context.restoreGState()

Do not create a graphics context merely to keep it for later. A context belongs to a particular drawing operation and can be invalid outside that operation. For images or PDF files, create an appropriate off-screen context with APIs such as UIGraphicsImageRenderer.

Building paths and geometric shapes

A CGPath describes geometry without deciding how that geometry will look. You can create a path with move(to:), addLine(to:), addCurve(to:control1:control2:) and closeSubpath(). After that, choose whether to stroke its outline, fill its area, or perform both operations.

This separation makes custom drawing flexible. The same path can have a blue fill and a dark outline, or it can be clipped before a gradient is applied:

let path = UIBezierPath()
path.move(to: CGPoint(x: 20, y: 100))
path.addLine(to: CGPoint(x: 140, y: 30))
path.addLine(to: CGPoint(x: 260, y: 100))
path.close()

UIColor.systemYellow.setFill()
path.fill()

UIColor.label.setStroke()
path.lineWidth = 2
path.stroke()

UIBezierPath is a UIKit-friendly wrapper around CGPath, while CGMutablePath belongs directly to Core Graphics. Both are useful. Bezier paths are convenient for ordinary iOS views, and mutable Core Graphics paths are helpful when constructing geometry from data, such as a route displayed on a map of Melbourne or a graph built from sensor readings.

Use the view’s bounds rather than hard-coded screen dimensions. This keeps the drawing correct across an iPhone SE, an iPad and different device orientations. Shapes should also account for contentScaleFactor, safe areas and the possibility that Auto Layout changes the view’s size after it is created.

Drawing with UIKit views

A custom UIView is generally the best starting point for interactive custom graphics. Put drawing code in draw(_:), keep the view’s public properties focused on visual state, and call setNeedsDisplay() whenever that state changes.

final class ProgressRingView: UIView {
    var progress: CGFloat = 0 {
        didSet {
            progress = min(max(progress, 0), 1)
            setNeedsDisplay()
        }
    }

    override func draw(_ rect: CGRect) {
        guard let context = UIGraphicsGetCurrentContext() else { return }

        let centre = CGPoint(x: bounds.midX, y: bounds.midY)
        let radius = min(bounds.width, bounds.height) * 0.35
        let startAngle = -CGFloat.pi / 2
        let endAngle = startAngle + (2 * CGFloat.pi * progress)

        context.setStrokeColor(UIColor.systemGreen.cgColor)
        context.setLineWidth(12)
        context.setLineCap(.round)

        context.addArc(
            center: centre,
            radius: radius,
            startAngle: startAngle,
            endAngle: endAngle,
            clockwise: false
        )
        context.strokePath()
    }
}

The view should draw from its current properties instead of changing its own data during rendering. Drawing may happen more frequently than expected, especially during scrolling or animation. A predictable render method is easier to debug and prevents visual glitches.

For touch handling, override methods such as touchesMoved or use gesture recognisers. A drawing view can convert touch locations into path points, but it should avoid creating a new subview for every point. For a sketching app, maintain a collection of strokes and redraw the single canvas when a stroke changes.

Working with colours, gradients and text

Core Graphics uses CGColor, while UIKit commonly exposes UIColor. Convert between them with .cgColor, but remember that colour spaces matter. A colour that looks correct in a simulator can behave differently when exported or used with wide-gamut assets.

Gradients are created with CGGradient. A simple linear gradient might look like this:

let colours = [
    UIColor.systemIndigo.cgColor,
    UIColor.systemPink.cgColor
] as CFArray

let locations: [CGFloat] = [0, 1]
let gradient = CGGradient(
    colorsSpace: CGColorSpaceCreateDeviceRGB(),
    colors: colours,
    locations: locations
)

context.drawLinearGradient(
    gradient!,
    start: CGPoint(x: 0, y: 0),
    end: CGPoint(x: bounds.maxX, y: bounds.maxY),
    options: []
)

Text drawing requires more care than shapes. Core Graphics can draw an NSAttributedString, but UIKit’s text layout tools are usually better for paragraphs, Dynamic Type and accessibility. Use Core Graphics for compact labels inside a visual, and use UILabel, UITextView or TextKit when the text needs wrapping, user interaction or VoiceOver support.

Australian users may view an app outdoors in bright Brisbane sunlight or on a small phone while travelling through Sydney. Strong contrast, scalable text and clear colour distinctions are practical concerns, not decorative extras. Avoid communicating status through colour alone, and test custom graphics with the accessibility settings available in iOS.

Improving performance and rendering quality

Custom drawing can be efficient because one view may replace a large hierarchy of small views. It can also become expensive when every frame recalculates complex paths, gradients and image effects. Keep geometry that does not change in a cached property, and rebuild it only when the view’s size or relevant data changes.

For static content, consider rasterising it into a UIImage with UIGraphicsImageRenderer. For animated content, calculate only what changes between frames. Instruments can reveal whether rendering, layout or image decoding is causing a performance problem. The Core Animation tools in Xcode are particularly useful when a custom view scrolls poorly.

Retina displays require attention to pixel alignment. A one-point line can appear soft if its edges fall between physical pixels. For a one-point horizontal or vertical stroke, align its position to the device’s pixel grid when crispness matters. Avoid assuming that one point always equals one physical pixel, since scale factors vary across iPhone and iPad hardware.

Use opaque = true only when the view genuinely has no transparency, and choose an appropriate background colour. Excessive transparency can increase compositing work. A custom chart used in an app for a local Australian business, for example, should remain responsive on older customer devices rather than relying on the newest iPhone available in a developer’s test collection.

Debugging coordinates and transforms

Many Core Graphics bugs are coordinate-system bugs. UIKit’s view coordinates usually start at the top-left, while some lower-level graphics operations use a coordinate system with a different vertical direction. Images, PDF drawing and manual transformations can introduce further changes.

When a shape appears upside down or in the wrong place, inspect the current transformation matrix and check the coordinate space of every point. Convert locations between views with convert(_:to:) and convert(_:from:) rather than guessing offsets. A temporary border or grid can make an incorrect bounds value immediately visible.

Use context.saveGState() before clipping or transforming, then restore the state as soon as that operation is complete. Clipping is powerful for circular avatars, progress indicators and masked illustrations, but an accidental clip can make later drawing disappear. Xcode’s view debugger can help with the surrounding view hierarchy, while logging the calculated rectangles and path points helps with the drawing itself.

A useful debugging habit is to build the visual in layers: background first, guide geometry second, main path third, labels last. Temporarily use bright colours for each layer and remove them after alignment is correct. This approach is valuable when adapting a design from a Figma file to multiple screen sizes, including the compact layouts common in Australian iOS testing.

Organising a maintainable drawing component

A custom drawing view should have a clear contract. Expose values such as progress, strokeColour, items or showsGrid, and keep Core Graphics implementation details private. This lets a view controller configure the component without knowing how paths or gradients are generated.

Separate data preparation from rendering when the visual is substantial. A chart can transform raw values into normalised points before draw(_:); the drawing method then focuses on clipping, styling and rendering. This structure improves testability and makes it easier to support different datasets, such as temperatures, rainfall or sales figures.

Keep drawing code compatible with light and dark appearance, Dynamic Type where text is involved, and right-to-left layouts when labels or directional shapes require it. Test with real content rather than only ideal sample values. If an app targets the Australian market, include dates, currency and measurements that match its users, while ensuring that the graphics remain independent from regional formatting rules.

Core Graphics is most approachable when each task is reduced to a sequence: calculate a rectangle, create a path, configure the context, draw, and restore state. Start with a small custom view such as a ring or badge, then add curves, clipping, gradients and animation as the geometry becomes familiar. The practical takeaway is to keep drawing state local, derive dimensions from bounds, invalidate only when visual data changes, and measure performance on real devices.

Updated: