Drawing with Core Graphics in iOS

Core Graphics is an API included in both Cocoa and Cocoa Touch. It allows you to draw graphic objects on the graphic destination.

So, the Core Graphics framework is based on the Quartz advanced drawing engine. It provides low-level, lightweight 2D rendering with unmatched output fidelity. You use this framework to handle path-based drawing, transformations, color management, offscreen rendering, patterns, gradients and shadings, image data management, image creation, and image masking, as well as PDF document creation, display, and parsing.

CoreGraphics include some helpful types: CGFloat, CGPoint, CGSize, CGRect.

CoreGraphics include some helpful classes: CGContext, CGImage, CGPath/CGMutablePath, CGColor, etc.

Let's build some simple shapes. The UIGraphicsImageRenderer class was introduced in iOS 10 as a modern class which allows closures. It also support the wide color gamut of devices like the iPad Pro.

func drawLines() {
    let renderer = UIGraphicsImageRenderer(size: CGSize(width: 280, height: 250))

    let img = renderer.image { ctx in
        ctx.cgContext.move(to: CGPoint(x: 20.0, y: 20.0))
        ctx.cgContext.addLine(to: CGPoint(x: 260.0, y: 230.0))
        ctx.cgContext.addLine(to: CGPoint(x: 100.0, y: 200.0))
        ctx.cgContext.addLine(to: CGPoint(x: 20.0, y: 20.0))

        ctx.cgContext.setLineWidth(10)
        ctx.cgContext.setStrokeColor(UIColor.black.cgColor)

        ctx.cgContext.strokePath()
    }

    imageView.image = img
}

func drawRectangle() {
    let renderer = UIGraphicsImageRenderer(size: CGSize(width: 280, height: 250))

    let img = renderer.image { ctx in
        let rectangle = CGRect(x: 0, y: 0, width: 280, height: 250)

        ctx.cgContext.setFillColor(UIColor.yellow.cgColor)
        ctx.cgContext.setStrokeColor(UIColor.gray.cgColor)
        ctx.cgContext.setLineWidth(20)

        ctx.cgContext.addRect(rectangle)
        ctx.cgContext.drawPath(using: .fillStroke)
    }

    imageView.image = img
}

func drawCircle() {
    let renderer = UIGraphicsImageRenderer(size: CGSize(width: 280, height: 250))

    let img = renderer.image { ctx in
        let rect = CGRect(x: 5, y: 5, width: 270, height: 240)

        ctx.cgContext.setFillColor(UIColor.blue.cgColor)
        ctx.cgContext.setStrokeColor(UIColor.black.cgColor)
        ctx.cgContext.setLineWidth(10)

        ctx.cgContext.addEllipse(in: rect)
        ctx.cgContext.drawPath(using: .fillStroke)
    }

    imageView.image = img
}

Custom Drawing on Views

First, let’s build a view that will draw a yellow star.

There are two steps for custom drawings:

  1. Create a UIView subclass.
  2. Override draw(_:) and add some Core Graphics drawing code.

Overriding the draw method

The most common place to draw using Core Graphics is in the draw method of UIView. This method is called when a view is first laid out and any time that the view needs to be redrawn.

Create a subclass of UIView called Star. The draw method shows up in the UIView template, but it’s commented out. Uncomment the draw method:

class Star: UIView {
    override func draw(_ rect: CGRect) {
        // Drawing code
    }
}

Note that the draw method is passed a rect parameter containing the dimensions available to you to draw in.

Describing a path

To draw both simple and complex shapes in Core Graphics, you first need to describe their paths. Paths are described with a CGPath object, but UIKit class UIBezierPath is often used, because it has additional functionality and can provide you with a CGPath object anyway, via its cgPath property.

Simple shapes are easy to define in UIBezierPath - it has initializers that define ovals, rectangles, rounded rectangles, and arcs. For example, the following will create a circle path that fits inside a rectangle:

UIBezierPath(rect: CGRect(x: 0, y: 0, width: 100, height: 100))

To create a complex path, after instantiating an empty UIBezierPath object, you’d move to the initial point of the path with the move method, draw lines to each point in the path with the addLine method, and finally close the path with the close method.

You’ll use the UIBezierPath method to draw a star. Add the following method to your Star class that returns a UIBezierPath object that describes the path to draw a star:

func getStarPath() -> UIBezierPath {
    let path = UIBezierPath()
    path.move(to: CGPoint(x: 12, y: 1.2))
    path.addLine(to: CGPoint(x: 15.4, y: 8.4))
    path.addLine(to: CGPoint(x: 23, y: 9.6))
    path.addLine(to: CGPoint(x: 17.5, y: 15.2))
    path.addLine(to: CGPoint(x: 18.8, y: 23.2))
    path.addLine(to: CGPoint(x: 12, y: 19.4))
    path.addLine(to: CGPoint(x: 5.2, y: 23.2))
    path.addLine(to: CGPoint(x: 6.5, y: 15.2))
    path.addLine(to: CGPoint(x: 1, y: 9.6))
    path.addLine(to: CGPoint(x: 8.6, y: 8.4))
    path.close()
    return path 
}

To draw the shape you defined with Core Graphics, you need a graphics context.

Drawing into the graphics context

The graphics context is where all your Core Graphics drawing is performed. You can get a reference to the current graphics context with the global UIGraphicsGetCurrentContext method.

Add a reference to the current graphics context in the draw method:

let context = UIGraphicsGetCurrentContext()

Now that you have a graphics context, you can set the stroke or fill color, add the path you defined earlier, and then draw the path using either a fill, a stroke, or both.

Draw the star path into the current graphics context using an orange fill. Use the cgColor property of UIColor to pass in a CGColor object.

context?.setFillColor(UIColor.orange.cgColor)
context?.addPath(getStarPath().cgPath)
context?.drawPath(using: .fill)

Saving and restoring graphics state

Every time you make a change to an attribute in the graphics context (such as setting the fill color, font name, line width, anti-aliasing, or transforms — the list goes on) you’re adjusting the graphics state, and any future graphics calls will be affected by these changes.

If you only want to adjust the graphics state temporarily for the current operation you’re performing, it’s a good idea to save the graphics state to the stack first and then restore the graphics state from the stack when you’re finished, to leave the graphics state as you found it.

Surround the drawing of the star path with saving and restoring the graphics state:

context?.saveGState()
//change graphics state
//draw operation (e.g. draw star)
context?.restoreGState()

Drawing paths with UIBezierPath drawing methods

An additional feature of the UIBezierPath wrapper for CGPath is the ability to stroke or fill a path into the current graphics context directly from the path object. Using the UIBezierPath drawing methods not only avoids the need for a reference to the graphics context, but will automatically perform the administrative detail of saving and restoring graphics state for you.

Replace the graphics context–focused code from earlier with the UIBezierPath drawing methods. You can set the fill on the UIColor class itself, and then fill the path by calling the fill method on the UIBezierPath object.

override func draw(_ rect: CGRect) {
    UIColor.orange.setFill()
    getStarPath().fill()
}

Add a fill property to the Star class, which determines whether the star should be filled or given a stroke.

var fill = false
override func draw(_ rect: CGRect) {
    if fill {
        UIColor.orange.setFill()
        getStarPath().fill()
    } else {
        UIColor.orange.setStroke()
        getStarPath().stroke()
    }
}

When the fill property is set, the star should be redrawn. However, the star is being drawn in the draw method, and you should never call the draw method directly. Instead, you should notify the system that the view needs to be redrawn with the setNeedsDisplay method.

Add a didSet property observer to the fill property, which calls setNeedsDisplay.

var fill: Bool = false {
    didSet {
        setNeedsDisplay()
    }   
}

Rendering views in Interface Builder

It would be great to see the star you’ve drawn. Let’s look at what you have so far in Interface Builder.

With the main storyboard open, drag in a temporary view controller and then drag a view into its root view.

In the Identity Inspector, give the view the custom class of Star.

Add the @IBDesignable attribute before the class declaration for Star.

@IBDesignable class Star: UIView {

Return to the main storyboard, and the star view should now render nicely. But it’s defaulting to not filled.

You can specify that a property be adjustable directly from Interface Builder by adding the @IBInspectable attribute before declaring the property.

Add the @IBInspectable attribute before the fill property in the Star class.

@IBInspectable var fill: Bool = false {

Return to the main storyboard and select the Attributes Inspector for the star view. You should find a new attribute, called Fill.

Select On, and your star view should appear filled in the canvas.

Creating a star-rating view

Now that the star view is ready, you can set up your star-rating view. Similar to the star view, the star-rating view will render in Interface Builder, and will have inspectable properties to customize its appearance.

Create a Rating class that subclasses UIView, and make it render in the storyboard with the @IBDesignable attribute.

@IBDesignable class Rating: UIView {

Set up a property to define how many stars the rating view should fill. Make the property inspectable, and include a property observer that registers that the view requires layout when it is set.

@IBInspectable var rating: Double = 3 {
    didSet {setNeedsLayout()}
}

Add the star subviews in the layoutSubviews method of the star-rating view. Check that the stars array is empty. If it is, you need to create the star views, adding them to the view and the stars array. You need to clear the background color of each star view because it will default to black otherwise when generated from the draw method. Finally, use the rating property to determine how many stars should be filled.

var stars: [Star] = []
let numberOfStars = 5
override func layoutSubviews() {
    if stars.count == 0 {
        // add stars
        for i in 0..<numberOfStars {
            let star = Star(frame: CGRect(x: CGFloat(30 * i), y: 0,
                width: 25, height: 25))
            star.backgroundColor = UIColor.clear
            self.addSubview(star)
            stars.append(star)

        } 
    }

    for (i,star) in stars.enumerated() {
        star.fill = Double(i) < rating
    } 
}

Open the main storyboard, and in the Identity Inspector, change the subclass of your temporary view to your new Rating class. Your rating view should now render nicely in the storyboard.

Play with the number of stars and rating properties in the Attributes Inspector, and change the look of the rating view in the canvas.

Now you want to make your ratings view interactive in your app, so that the user can select ratings.

Override the touchesBegan method in the Rating class. Determine the index of the star view the user touched from the stars array, and use this index to set the rating property.

override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
    guard let touch = touches.first else {return}
    guard let star = touch.view as? Star else {return}
    guard let starIndex = stars.index(of: star) else {return}
    rating = Double(starIndex) + 1
}

Because the rating property calls setNeedsDisplay in its didSet property observer, setting the rating property is all that’s needed for the star-rating view to update visually when the user selects a different rating.

For auto layout and scroll views to manage the size of the star-rating view correctly, you'll need to specify its intrinsic content size.

Override the intrinsicContentSize property in the Rating class.

override var intrinsicContentSize: CGSize {
    return CGSize(width: 30 * numberOfStars, height: 25)
}