Welcome back to our modern Core Graphics with Swift tutorial series!
In the first part of the tutorial series, you learned about drawing lines and arcs and using Xcode’s interactive storyboard features.
In this second part, you’ll delve further into Core Graphics, learning about drawing gradients and manipulating CGContext
with transformations.
Core Graphics
You’re now going to leave the comfortable world of UIKit and enter the underworld of Core Graphics.
This image from Apple describes the relevant frameworks conceptually:
UIKit is the top layer, and it’s also the most approachable. You’ve used UIBezierPath
, which is a UIKit wrapper of the Core Graphics CGPath
.
One thing to know about lower layer Core Graphics objects and functions is that they always have the prefix CG, so they are easy to recognize. Another fun fact: CG functions are C functions, so you don’t call them with explicit parameter names, which is different than when you call Swift functions.
Getting Going with Graph View
By the time you’re at the end of this session, you’ll create a graph view that looks like this by using sample historical data:
Before drawing on the graph view, you’ll set it up in the storyboard and create the code that animates the transition to show the graph view.
The complete view hierarchy will look like this:
If you don’t have it already, download a copy of Flo from the first Core Graphics tutorial.
Go to File\New\File…, choose the iOS\Source\Cocoa Touch Class template and click Next. Enter the name GraphView as the class name, choose the subclass UIView and set the language to Swift. Click Next then Create.
Go to Main.storyboard and drag a UIView on to the view controller’s view.
This will contain the Graph and Counter Views, so make it a subview of ViewController’s main view, but place it in front of Counter View.
Your Document Outline should look like this:
In the Size Inspector, set X=150, Y=50, Width=300, and Height=300:
- With the view selected, Control-drag slightly left, and choose Width from the popup menu.
- Then control-drag slightly up and choose Height from the popup menu.
- Next, control-drag left from inside the view to outside the view, and choose Center Vertically in Container.
- Finally, control-drag up from inside the view to outside the view and choose Center Horizontally in Container.
When creating views, it’s often helpful to give the view you’re working with a temporary color, so that you can easily see what you’re doing.
In the Attributes Inspector, color the background yellow.
Drag another UIView onto the yellow view, and make sure that it becomes a subview.
In the Identity Inspector, change the class of the view to GraphView.
In the Size Inspector, set X=0, Y=25, Width=300, and Height=250:
In the Document Outline, drag the Counter View to make it a subview of the yellow view, and make sure it’s positioned behind its sibling Graph View.
After moving the Counter View, the auto layout constraints will turn orange. Select the Counter View, and look at the bottom right of the storyboard. Find and click Resolve Auto Layout Issues, and choose Selected Views: Clear Constraints.
You can reset to default constraints, because the Counter View is now snug inside the Container View.
Click the name of the yellow view in the Document Outline slowly twice to rename it, and call it Container View. Your Document Outline should look like this:
The reason you need a Container View is to make an animated transition between the Counter View and the Graph View.
Go to ViewController.swift and add property outlets for the Container and Graph Views:
@IBOutlet weak var containerView: UIView! @IBOutlet weak var graphView: GraphView! |
This creates an outlet for the container view and graph view. Now let’s hook them up to the views you created in the storyboard.
Go back to Main.storyboard and hook up the Graph View and the Container View to the outlets:
Set up the Animated Transition
Still in Main.storyboard, drag a UITapGestureRecognizer from the Object Library to the Container View in the Document Outline:
Go to ViewController.swift and add this property to the top of the class:
var isGraphViewShowing = false |
This simply marks whether the graph view is currently showing.
Now add the tap method to do the transition:
@IBAction func counterViewTap(gesture:UITapGestureRecognizer?) { if (isGraphViewShowing) { //hide Graph UIView.transitionFromView(graphView, toView: counterView, duration: 1.0, options: UIViewAnimationOptions.TransitionFlipFromLeft | UIViewAnimationOptions.ShowHideTransitionViews, completion:nil) } else { //show Graph UIView.transitionFromView(counterView, toView: graphView, duration: 1.0, options: UIViewAnimationOptions.TransitionFlipFromRight | UIViewAnimationOptions.ShowHideTransitionViews, completion: nil) } isGraphViewShowing = !isGraphViewShowing } |
UIView.transitionFromView(_:toView:duration:options:completion:)
performs a horizontal flip transition. Other transitions are cross dissolve, vertical flip and curl up or down. The transition masks the ShowHideTransitionViews constant, so you don’t have to remove the view to prevent it from being shown.
Add this code at the end of btnPushButton(_:):
if isGraphViewShowing { counterViewTap(nil) } |
This makes it so that if the user presses the plus button while the graph is showing, the display will swing back to show the counter.
Lastly, to get this transition working, go back to Main.storyboard and hook up your tap gesture to the newly added counterViewTap(gesture:)
:
Build and run the application. Currently you’ll see the graph view when you start the app. Later on, you’ll set the graph view hidden, so the counter view will appear first. Tap it, and you’ll see the transition flipping.
Analysis of the Graph View
Remember the Painter’s Model from Part 1? It explains that drawing with Core Graphics is done from the back to the front, so you need an order in mind before you code. For Flo’s graph, that would be:
- Gradient background view
- Clipped gradient under the graph
- The graph line
- The circles for the graph points
- Horizontal graph lines
- The graph labels
Drawing a Gradient
You’ll now draw a gradient in the Graph View’s context.
Go to GraphView.swift and replace the code with:
import UIKit @IBDesignable class GraphView: UIView { //1 - the properties for the gradient @IBInspectable var startColor: UIColor = UIColor.redColor() @IBInspectable var endColor: UIColor = UIColor.greenColor() override func drawRect(rect: CGRect) { //2 - get the current context let context = UIGraphicsGetCurrentContext() let colors = [startColor.CGColor, endColor.CGColor] //3 - set up the color space let colorSpace = CGColorSpaceCreateDeviceRGB() //4 - set up the color stops let colorLocations:[CGFloat] = [0.0, 1.0] //5 - create the gradient let gradient = CGGradientCreateWithColors(colorSpace, colors, colorLocations) //6 - draw the gradient var startPoint = CGPoint.zeroPoint var endPoint = CGPoint(x:0, y:self.bounds.height) CGContextDrawLinearGradient(context, gradient, startPoint, endPoint, 0) } } |
There are a few things to go over here:
- You set up the start and end colors for the gradient as
@IBInspectable
properties, so that you’ll be able to change them in the storyboard. - CG drawing functions need to know the context in which they will draw, so you use the UIKit method
UIGraphicsGetCurrentContext()
to obtain the current context. That’s the one thatdrawRect(_:)
draws into. - All contexts have a color space. This could be CMYK or grayscale, but here you’re using the RGB color space.
- The color stops describe where the colors in the gradient change over. In this example, you only have two colors, red going to green, but you could have an array of three stops, and have red going to blue going to green. The stops are between 0 and 1, where 0.33 is a third of the way through the gradient.
- Create the actual gradient, defining the color space, colors and color stops.
- Finally, you draw the gradient.
CGContextDrawLinearGradient()
takes the following parameters:- The
CGContext
in which to draw - The
CGGradient
with color space, colors and stops - The start point
- The end point
- Option flags to extend the gradient
- The
The gradient will fill the entire rect
of drawRect(_:)
.
Set up Xcode so that you have a side-by-side view of your code and the storyboard using the Assistant Editor, and you’ll see the gradient appear on the Graph View.
In the storyboard, select the Graph View. Then in the Attributes Inspector, change Start Color to RGB(250, 233, 222), and End Color RGB(252, 79, 8):
Now for some clean up duty. In Main.storyboard, select each view in turn, except for the main ViewController view, and set the Background Color to clear color. You don’t need the yellow color any more, and the push button views should have a transparent background too.
Run the application, and you’ll notice the transition is now a lot more stylish.
Clipping areas
When you used the gradient just now, you filled the whole of the view’s context area. However, you can create paths to use as clipping areas instead of being used for drawing.
Go to GraphView.swift, and add this code to the top of drawRect(_:)
:
let width = rect.width let height = rect.height //set up background clipping area var path = UIBezierPath(roundedRect: rect, byRoundingCorners: UIRectCorner.AllCorners, cornerRadii: CGSize(width: 8.0, height: 8.0)) path.addClip() |
This will create a clipping area that constrains the gradient. You’ll use this same trick shortly to draw a second gradient under the graph line.
Build and run the application and see that your graph view has nice, rounded corners:
Speed Note: Drawing static views Core Graphics is generally quick enough, but if your views move around or need frequent redrawing, you should use Core Animation layers. It’s optimized so that the GPU, not the CPU, handles most of the processing. In contrast, the CPU processes view drawing performed by drawRect(_:)
.
Instead of using a clipping path, you can create rounded corners using the cornerRadius
property of a CALayer, but you should optimize for your situation. For a good lesson on this concept, check out Custom Control Tutorial for iOS and Swift: A Reusable Knob by Mikael Konutgan and Sam Davies, where you’ll use Core Animation to create a custom control.
Tricky Calculations for Graph Points
Now you’ll take a short break from drawing to make the graph. You’ll plot 7 points; the x-axis will be the ‘Day of the Week’ and the y-axis will be the ‘Number of Glasses Drunk’.
First, set up sample data for the week.
Still in GraphView.swift, at the top of the class, add this property:
//Weekly sample data var graphPoints:[Int] = [4, 2, 6, 4, 5, 8, 3] |
This holds sample data that represents seven days.
Next, add this code to the end of drawRect(_:)
:
//calculate the x point let margin:CGFloat = 20.0 var columnXPoint = { (column:Int) -> CGFloat in //Calculate gap between points let spacer = (width - margin*2 - 4) / CGFloat((self.graphPoints.count - 1)) var x:CGFloat = CGFloat(column) * spacer x += margin + 2 return x } |
The x-axis points consist of 7 equally spaced points. The above code above is a closure expression. It could have been added as a function, but for small calculations like this, it’s logical to keep them inline.
columnXPoint
takes a column as a parameter, and returns a value where the point should be on the x-axis.
Add the code to calculate the y-axis points to the end of drawRect(_:)
:
// calculate the y point let topBorder:CGFloat = 60 let bottomBorder:CGFloat = 50 let graphHeight = height - topBorder - bottomBorder let maxValue = maxElement(graphPoints) var columnYPoint = { (graphPoint:Int) -> CGFloat in var y:CGFloat = CGFloat(graphPoint) / CGFloat(maxValue) * graphHeight y = graphHeight + topBorder - y // Flip the graph return y } |
columnYPoint
is also a closure expression that takes the value from the array for the day of the week as its parameter. It returns the y position, between 0 and the greatest number of glasses drunk.
Because the origin is in the top-left corner and you draw a graph from an origin point in the bottom-left corner, columnYPoint
adjusts its return value so that the graph is oriented as you would expect.
Continue by adding line drawing code to the end of drawRect(_:)
:
// draw the line graph UIColor.whiteColor().setFill() UIColor.whiteColor().setStroke() //set up the points line var graphPath = UIBezierPath() //go to start of line graphPath.moveToPoint(CGPoint(x:columnXPoint(0), y:columnYPoint(graphPoints[0]))) //add points for each item in the graphPoints array //at the correct (x, y) for the point for i in 1..<graphPoints.count { let nextPoint = CGPoint(x:columnXPoint(i), y:columnYPoint(graphPoints[i])) graphPath.addLineToPoint(nextPoint) } graphPath.stroke() |
In this block, you create the path for the graph. The UIBezierPath
is built up from the x and y points for each element in graphPoints
.
The Graph View in the storyboard should now look like this:
Now that you verified the line draws correctly, remove this from the end of drawRect(_:)
:
graphPath.stroke() |
That was just so that you could check out the line in the storyboard and verify that the calculations are correct.
A Gradient Graph
You’re now going to create a gradient underneath this path by using the path as a clipping path.
First set up the clipping path at the end of drawRect(_:)
:
//Create the clipping path for the graph gradient //1 - save the state of the context (commented out for now) //CGContextSaveGState(context) //2 - make a copy of the path var clippingPath = graphPath.copy() as UIBezierPath //3 - add lines to the copied path to complete the clip area clippingPath.addLineToPoint(CGPoint( x: columnXPoint(graphPoints.count - 1), y:height)) clippingPath.addLineToPoint(CGPoint( x:columnXPoint(0), y:height)) clippingPath.closePath() //4 - add the clipping path to the context clippingPath.addClip() //5 - check clipping path - temporary code UIColor.greenColor().setFill() let rectPath = UIBezierPath(rect: self.bounds) rectPath.fill() //end temporary code |
A section-by-section breakdown of the above code:
CGContextSaveGState
is commented out for now — you’ll come back to this in a moment once you understand what it does.- Copy the plotted path to a new path that defines the area to fill with a gradient.
- Complete the area with the corner points and close the path. This adds the bottom-right and bottom-left points of the graph.
- Add the clipping path to the context. When the context is filled, only the clipped path is actually filled.
- Fill the context. Remember that
rect
is the area of the context that was passed todrawRect(_:)
.
Your Graph View in the storyboard should now look like this:
Next, you’ll replace that lovely green with a gradient you create from the colors used for the background gradient.
Remove the temporary code with the green color fill from the end of drawRect(_:)
, and add this code instead:
let highestYPoint = columnYPoint(maxValue) startPoint = CGPoint(x:margin, y: highestYPoint) endPoint = CGPoint(x:margin, y:self.bounds.height) CGContextDrawLinearGradient(context, gradient, startPoint, endPoint, 0) //CGContextRestoreGState(context) |
In this block, you find the highest number of glasses drunk and use that as the starting point of the gradient.
You can’t fill the whole rect
the same way you did with the green color. The gradient would fill from the top of the context instead of from the top of the graph, and the desired gradient wouldn’t show up.
Take note of the commented out CGContextRestoreState — you’ll remove the comments after you draw the circles for the plot points.
At the end of drawRect(_:)
, add this:
//draw the line on top of the clipped gradient graphPath.lineWidth = 2.0 graphPath.stroke() |
This code draws the original path.
Your graph is really taking shape now:
At the end of drawRect(_:)
, add this:
//Draw the circles on top of graph stroke for i in 0..<graphPoints.count { var point = CGPoint(x:columnXPoint(i), y:columnYPoint(graphPoints[i])) point.x -= 5.0/2 point.y -= 5.0/2 let circle = UIBezierPath(ovalInRect: CGRect(origin: point, size: CGSize(width: 5.0, height: 5.0))) circle.fill() } |
This code draws the plot points and is nothing new. It fills a circle path for each of the elements in the array at the calculated x and y points.
Hmmm…but what’s showing up in the storyboard are not nice, round circle points! Whaaaaaaat? Press on, it’ll all come together.
Context States
Graphics contexts can save states. When you set many context properties, such as fill color, transformation matrix, color space or clip region, you’re actually setting them for the current graphics state.
You can save a state by using CGContextSaveGState()
, which pushes a copy of the current graphics state onto the state stack. You can also make changes to context properties, but when you call CGContextRestoreGState()
, the original state is taken off the stack and the context properties revert.
Still in GraphView.swift, in drawRect(_:)
, uncomment the CGContextSaveGState()
that takes place before creating the clipping path, and uncomment the CGContextRestoreGState()
that takes place after the clipping path has been used.
By doing this, you:
- Push the original graphics state onto the stack with
CGContextSaveGState()
. - Add the clipping path to a new graphics state.
- Draw the gradient within the clipping path.
- Restore the original graphics state with
CGContextRestoreGState()
— this was the state before you added the clipping path.
Your graph line and circles should be much clearer now:
At the end of drawRect(_:)
, add the code to draw the three horizontal lines:
//Draw horizontal graph lines on the top of everything var linePath = UIBezierPath() //top line linePath.moveToPoint(CGPoint(x:margin, y: topBorder)) linePath.addLineToPoint(CGPoint(x: width - margin, y:topBorder)) //center line linePath.moveToPoint(CGPoint(x:margin, y: graphHeight/2 + topBorder)) linePath.addLineToPoint(CGPoint(x:width - margin, y:graphHeight/2 + topBorder)) //bottom line linePath.moveToPoint(CGPoint(x:margin, y:height - bottomBorder)) linePath.addLineToPoint(CGPoint(x:width - margin, y:height - bottomBorder)) let color = UIColor(white: 1.0, alpha: 0.3) color.setStroke() linePath.lineWidth = 1.0 linePath.stroke() |
Nothing in this code is new. All you’re doing is moving to a point and drawing a horizontal line.
Adding the Graph Labels
Now you’ll add the labels to make the graph user-friendly.
Go to ViewController.swift and add these outlet properties for the labels:
//Label outlets @IBOutlet weak var averageWaterDrunk: UILabel! @IBOutlet weak var maxLabel: UILabel! |
This adds outlets for the two labels that you want to dynamically change text for (the average water drunk label, and the max water drunk label).
Now go to Main.storyboard and add the following UILabels as subviews of the Graph View:
- “Water Drunk”
- “Average:”
- “2” (averageWaterDrunk)
- “99” (maxLabel). Right aligned
- “0”. Right aligned
- Labels for each day of a week — the text for each will be changed in code. Center aligned.
Shift-select all the labels, and then change the fonts to custom Avenir Next Condensed, Medium style.
Connect averageWaterDrunk
and maxLabel
to the corresponding labels in Main.storyboard. Control-drag from View Controller to the correct label and choose the outlet from the pop up:
For each weekday label, go into the Attributes Inspector and change the View’s Tag corresponding to the day, so that the first one is 1 and the last one is 7.
Now that you’ve finished setting up the graph view, in Main.storyboard select the Graph View and check Hidden so the graph doesn’t appear when the app first runs.
Go to ViewController.swift and add this method to set up the labels:
func setupGraphDisplay() { //Use 7 days for graph - can use any number, //but labels and sample data are set up for 7 days let noOfDays:Int = 7 //1 - replace last day with today's actual data graphView.graphPoints[graphView.graphPoints.count-1] = counterView.counter //2 - indicate that the graph needs to be redrawn graphView.setNeedsDisplay() maxLabel.text = "\(maxElement(graphView.graphPoints))" //3 - calculate average from graphPoints let average = graphView.graphPoints.reduce(0, +) / graphView.graphPoints.count averageWaterDrunk.text = "\(average)" //set up labels //day of week labels are set up in storyboard with tags //today is last day of the array need to go backwards //4 - get today's day number let dateFormatter = NSDateFormatter() let calendar = NSCalendar.currentCalendar() let componentOptions:NSCalendarUnit = .WeekdayCalendarUnit let components = calendar.components(componentOptions, fromDate: NSDate()) var weekday = components.weekday let days = ["S", "S", "M", "T", "W", "T", "F"] //5 - set up the day name labels with correct day for i in reverse(1...days.count) { let labelView = graphView.viewWithTag(i) as UILabel if weekday == 7 { weekday = 0 } labelView.text = days[weekday--] if weekday < 0 { weekday = days.count - 1 } } } |
This looks a little burly, but it’s required to set up the calendar and retrieve the current day of the week. Take it in sections:
- You set today’s data as the last item in the graph’s data array. In the final project, which you can download at the end of Part 3, you’ll expand on this by replacing it with 60 days of sample data, and you’ll include a method that splits out the last x number of days from an array, but that is beyond the scope of this session. :]
- Redraws the graph in case there are any changes to today’s data.
- Here you use Swift’s
reduce
to calculate the average glasses drunk for the week; it’s a very useful method to sum up all the elements in an array. - This section put the current day’s number from the iOS calendar into the property weekday.
- This loop just goes from 7 to 1, gets the view with the corresponding tag number and extracts the correct day title from the days array.
Note: This Swift Functional Programming Tutorial explains functional programming in some depth.
Note: Dates can get complicated. David Ronnqvist’s Working with Dates explains everything.
Still in ViewController.swift, call this new method from counterViewTap(_:)
. In the else
part of the conditional, where the comment says show graph, add this code:
setupGraphDisplay() |
Run the application, and click the counter. Hurrah! The graph swings into view in all its glory!
Mastering the Matrix
Your app is looking really sharp! The counter view you created in part one could be improved though, like by adding markings to indicate each glass to be drunk:
Now that you’ve had a bit of practice with CG functions, you’ll use them to rotate and translate the drawing context.
Notice that these markers radiate from the center:
As well as drawing into a context, you have the option to manipulate the context by rotating, scaling and translating the context’s transformation matrix.
At first, this can seem confusing, but after you work through these exercises, it’ll make more sense. The order of the transformations is important, so first I’ll outline what you’ll be doing with diagrams.
The following diagram is the result of rotating the context and then drawing a rectangle in the center of the context.
The black rectangle is drawn before rotating the context, then the green one, then the red one. Two things to notice:
- The context is rotated at the top left (0,0)
- The rectangle is still being drawn in the center of the context, but after the context has been rotated.
When you’re drawing the counter view’s markers, you’ll translate the context first, then you’ll rotate it.
In this diagram, the rectangle marker is at the very top left of the context. The blue lines outline the translated context, then the context rotates (red dashed line) and is translated again.
When the red rectangle marker is finally drawn into the context, it’ll appear in the view at an angle.
After the context is rotated and translated to draw the red marker, it needs to be reset to the center so that the context can be rotated and translated again to draw the green marker.
Just as you saved the context state with the clipping path in the Graph View, you’ll save and restore the state with the transformation matrix each time you draw the marker.
Go to CounterView.swift and add this code to the end of drawRect(_:)
to add the markers to the counter:
//Counter View markers let context = UIGraphicsGetCurrentContext() //1 - save original state CGContextSaveGState(context) outlineColor.setFill() let markerWidth:CGFloat = 5.0 let markerSize:CGFloat = 10.0 //2 - the marker rectangle positioned at the top left var markerPath = UIBezierPath(rect: CGRect(x: -markerWidth/2, y: 0, width: markerWidth, height: markerSize)) //3 - move top left of context to the previous center position CGContextTranslateCTM(context, rect.width/2, rect.height/2) for i in 1...NoOfGlasses { //4 - save the centred context CGContextSaveGState(context) //5 - calculate the rotation angle var angle = arcLengthPerGlass * CGFloat(i) + startAngle - π/2 //rotate and translate CGContextRotateCTM(context, angle) CGContextTranslateCTM(context, 0, rect.height/2 - markerSize) //6 - fill the marker rectangle markerPath.fill() //7 - restore the centred context for the next rotate CGContextRestoreGState(context) } //8 - restore the original state in case of more painting CGContextRestoreGState(context) |
Here’s what you’ve just done:
- Before manipulating the context’s matrix, you save the original state of the matrix.
- Define the position and shape of the path — but you’re not drawing it yet.
- Move the context so that rotation happens around the context’s original center. (Blue lines in the previous diagram.)
- For each mark, you first save the centered context state.
- Using the individual angle previously calculated, you determine the angle for each marker and rotate and translate the context.
- Draw the marker rectangle at the top left of the rotated and translated context.
- Restore the centered context’s state.
- Restore the original state of the context that had no rotations or translations.
Whew! Nice job hanging in there for that. Now build and run the application, and admire Flo’s beautiful and informative UI:
Where to Go to From Here?
Here is Flo, complete with all of the code you’ve developed so far.
At this point, you’ve learned how to draw paths, gradients and how to change the context’s transformation matrix.
In the third and final part of this Core Graphics tutorial, you’ll create a patterned background and draw a vector medal image.
If you’ve any questions or comments, please join me in the forum below.
Modern Core Graphics with Swift: Part 2 is a post from: Ray Wenderlich
The post Modern Core Graphics with Swift: Part 2 appeared first on Ray Wenderlich.