Drawing with UIKit’s CGPath
This is the 3rd article in my current Bezier Path series. This article is focused on teaching exactly how to use the CGPath object, and, more specifically, the curves.
Introduction
Here, I’ll teach how to use the CGPath object whilst drawing shapes, using this object drawing methods. This might be easier after you read the first article in this series: Bézier Fundaments for Swift.
This article will be divided into 5 parts:
- Workplace (setting up the project)
- Drawing a simple shape (to understand what we need to do besides draw)
- Drawing our first curved shape
- Drawing this article banner!
So prepare yourself, drawing with swift won’t be alien technology anymore!
Workplace
After creating a new, empty project, all I did was:
- Changed the AccentColor to a custom green (#007E05);
- Deleted the ViewController.swift file and everything inside the main.storyboard file;
- Added a Tab Bar Controller and connected 3 view controllers to it;
- Created 3 new view controllers (SquareViewController, DShapeViewController, and BannerViewController), connected them to their screens in the storyboard and added labels to them (to differentiate the screens)
This way, we can easily see our 3 different implementations without deleting code.
Drawing a simple shape
Our objective in this section it’s to draw a simple red square (see next image) using the drawLine method.
First things first, in our SquareViewController, we need to start with setting up our view's lifecycle (creation and updates)
- Creating an attribute that will store our object,
- Center our object inside the viewDidLayoutSubviews, and
- Inside the viewDidLoad, create a custom UIView class, with pre-defined size and add it to the screen:
class SquareViewController: UIViewController {
weak var squareView: UIView?
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
squareView?.center = self.view.center
}
override func viewDidLoad() {
super.viewDidLoad()
let square = SquareView(frame: CGRect(
origin: .zero,
size: CGSize(width: 100, height: 100)))
self.view.addSubview(square)
self.squareView = square
}
}
This way, the app re-centers every time the layoutSubviews method is invoked (like when the device is rotated). Depending on what you are doing, you might want to redraw the view.
Now that the lifecycle is created, we need to create our custom class that will be doing all the drawings. To draw with the CGPath, we need to:
- Create a class and inherit the UIView superclass and override the draw(_ rect:) method;
- Get and defer the current core graphics context;
- And draw using the context.
Let’s start by setting up our class:
class SquareView: UIView {
override func draw(_ rect: CGRect) { }
}
With that, we already have the class that we called in the ViewController. Now, we need to retrieve the CGContext and configure some stuff:
override func draw(_ rect: CGRect) {
guard let context = UIGraphicsGetCurrentContext() else {
return
}
context.saveGState()
defer {
context.restoreGState()
}
}
Ok… what's going on here? This is new. We are accessing the Core Graphics drawing API (Quartz 2D), getting its environment context (by calling the UIGraphicsGetCurrentContext and calling the saveGState method) and drawing on it. After that, we need to inform to when we are done with it (by calling the restoreGState method). We can do this last step by using the defer keyword or by just calling the method at the end of the function we are overriding.
Ok, let's understand how we will draw this square. In this case, we will use positive values, and the screen works by going right or down every time we increase the X or Y value (respectively).
After this, we can finally start drawing:
context.beginPath()context.move(to: .zero)
context.addLine(to: CGPoint(x: rect.width, y: 0))
context.addLine(to: CGPoint(x: rect.width, y: rect.height))
context.addLine(to: CGPoint(x: 0, y: rect.height))
context.addLine(to: .zero)context.closePath()
Don't forget to close the path! This is important to avoid weird artifacts, but also important when you want to draw more than one shape/stroke that is not connected with the same object.
After this, all we need to make it color our shape:
context.setFillColor(UIColor.tintColor.cgColor)
context.fillPath()
Hey, this wasn’t bad at all! And we got a pretty neat screen too that works well both in portrait and landscape:
If you need my full code, you can get it here, in my GitHub repo.
Cool! Let’s go to our next view controller and draw something with a curve!
Drawing our first curved shape
Our first curved shape will be a simple D shape like the following image.
This way, we can showcase one simple curved path to learn how it works exactly. Here, we will finally use the Bézier Curve method! Instead of just drawing a line to a given point, we need to pass the point we need to pass de destination point and two Control Points. But… what is a Control Point?
I explain it better in this article (if you didn’t read it, go read it, this will make things easier!), but basically, it’s a way to calculate where each point in the curve should be, using the two extra points as guides! We can see in the above image the origin and final point (big red circles) and its control points (small red circles). In the images below, we can see what we can achieve with the same curve while messing around with the control points' positions:
Ok, now that you have an idea of how it works, let’s code it! In our DShapeViewController, we will use pretty much the same code, but the class we will create will be called DShapeView, we will change the background color, add a stroke, and, in the drawing stage, we will use the addCurve method.
So, let's add in our viewDidLoad method the following line:
dShape.backgroundColor = .white
And let's start our new class:
class DShapeView: UIView {
final let strokeWidth: CGFloat = 5
override func draw(_ rect: CGRect) { }
}
I added a final attribute for our stroke width that will come in handy later. Everything else in the draw method will stay the same BUT the way we color our view and the drawing steps:
override func draw(_ rect: CGRect) {
guard let context = UIGraphicsGetCurrentContext() else {
return
}
context.saveGState()
defer {
context.restoreGState()
}
// Our new drawing code will be here
// Our color code will be here
context.strokePath()
context.fillPath()
}
When you are drawing with CGPath and you have strokePath and fillPath methods, make sure to call them in the correct order, since that can also give you some weird results.
And before the strokePath and fillPath methods, we need to configure the color we want:
context.setFillColor(UIColor.white.cgColor)
context.setStrokeColor(UIColor.tintColor.cgColor)
context.setLineWidth(5)
Now that we have everything in place, let’s find out which values we need for our points. Given we are imagining a frame 100x100 (width and height), we would end up with something like the following image.
To achieve our “D” shape, we will need to:
- Move to the first point;
- Add a curve to the second point;
- Define our curve control points (top right and bottom right)
- Add a line back to the first point
Implementing it we would have something like so:
context.move(to: CGPoint(x: strokeWidth, y: strokeWidth))
context.addCurve(
to: CGPoint(x: strokeWidth, y: rect.height - strokeWidth),
control1: CGPoint(x: rect.width * 0.75, y: strokeWidth),
control2: CGPoint(x: rect.width * 0.75, y: rect.height - strokeWidth))
context.addLine(to: CGPoint(x: strokeWidth, y: strokeWidth))
Remember that I said before that I would use the strokeWidth attribute? This is why. The way the CGPath draws is centred in the line, so, with big widths you can end up with some of it outside the view, making the drawing in the end weird:
The drawing process is correct, but our shape is a little outside the view frame. To make sure we will get all we want, we need to push the drawing inside the box, that's why for each point that touches the limit of the view I'm adding or removing value.
Now, running it in the simulator will give us the following screen:
If you need my full code, you can get it here, in my GitHub repo.
Nice! Let’s do something more challenging!
Drawing this article banner
Ok, now that we have the fundamentals, let’s go code this article banner as our new app background! But… where to start? As always, let’s break our problem into smaller parts.
As we can see, now we have 2 shapes to keep track of - the top and the bottom. Because of it, our view's lifecycle will change. Now, we don't need to keep track of anything in our BannerViewController. We just simply go to the storyboard, select the third view controller, select its view, and change its class to the view we will write: BannerView. We can see those steps in the following images:
Ok, now we need to create our class, but we will do something different. You might've realized that I'm using only one class for everything. That's because I plan to do 2 drawings in the same view, and the view will expand to correspond to the viewcontroller background.
Finally, let's start coding. We will call two methods in the draw method: createTopLayer and createBottomLayer, like so:
class BannerView: UIView {
override func draw(_ rect: CGRect) {
createTopLayer(rect)
createBottomLayer(rect)
}
func createTopLayer(_ rect: CGRect) { }
func createBottompLayer(_ rect: CGRect) { }
}
Now, we will add the same code to them (not the best practices, but for the sake of simplicity we will do it anyway):
guard let context = UIGraphicsGetCurrentContext() else { return }
context.saveGState()
context.setFillColor(UIColor.tintColor.cgColor)
// our drawing code will be here
context.fillPath()
context.restoreGState()
We are getting and saving the context, setting the color, and after the commented part we are filling the path and restoring the state. Pretty much all the setup is done, now we need to figure out how our drawing will work.
Now, into coding the shapes. As we can see in the first top image, the banner is made of the same shape, with one of them being the mirrored one. The shape can be broken into 3 straight lines and 2 simple Bézier ones.
We can see then, to achieve our top shape we need to:
- Move to the 1st (I‘ll use the top left);
- Add a line to the 2nd (top right);
- Add a line to the 3rd (center right);
- Add a big curve to the 4th point (top left);
- Add a small curve to the 5th point (also at the top left)
- Add a line back to the 1st point.
This time, for the point positions, we will use relative values. Click here to know what are they and how to calculate them, but, summing it up, we can treat the min and max positions of the view as 0 and 1, respectively, and multiply the value we obtained by the width or height to achieve the correct position in the app screen. This way, the calculations will work with any screen size.
So, our createTopLayer drawing code will be like this:
context.beginPath()
context.move(to: .zero)
context.addLine(to: CGPoint(x: rect.width, y: 0))
context.addLine(to: CGPoint(x: rect.width, y: rect.height*0.3125))
context.addCurve(to: CGPoint(x: 40, y: 75),
control1: CGPoint(x: rect.width-40, y: rect.height*0.15625),
control2: CGPoint(x: rect.width*3/5, y: rect.height*0.09375))
context.addCurve(to: CGPoint(x: 0, y: 50),
control1: CGPoint(x: 10, y: 75),
control2: CGPoint(x: 0, y: 65))
context.addLine(to: .zero)
context.closePath()
Note that all the pre-coded values that are being used in the multiplications are the relative values I mentioned before. The createBottomLayer will also use them:
context.beginPath()
context.move(to: .zero)
context.move(to: CGPoint(x: 0, y: rect.height*0.6875))context.addCurve(to: CGPoint(x: rect.width-40, y: rect.height-75),
control1: CGPoint(x: 40, y: rect.height*0.84375),
control2: CGPoint(x: rect.width*2/5, y: rect.height*0.90625))context.addCurve(to: CGPoint(x: rect.width, y: rect.height-50),
control1: CGPoint(x: rect.width-10, y: rect.height-75),
control2: CGPoint(x: rect.width, y: rect.height-65))
context.addLine(to: CGPoint(x: rect.width, y: rect.height))
context.addLine(to: CGPoint(x: 0, y: rect.height))context.addLine(to: CGPoint(x: 0, y: rect.height*0.6875))
context.addLine(to: .
Now, if we run the code:
Nice!
If you need my complete code, you can get it here, in my GitHub repo.