Quantcast
Channel: Kodeco | High quality programming tutorials: iOS, Android, Swift, Kotlin, Unity, and more
Viewing all articles
Browse latest Browse all 4396

Core Graphics Tutorial Part 2: Gradients and Contexts

$
0
0
Update note: This tutorial has been updated to iOS 11, Swift 4, and Xcode 9 by Andrew Kharchyshyn. The original tutorial was written by Caroline Begbie.

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 with Core Graphics, and using Xcode’s interactive storyboard features.

In this second part, you’ll delve further into Core Graphics, learning about drawing gradients and manipulating CGContexts 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.

The Core Graphics framework is based on the Quartz advanced drawing engine. It provides low-level, lightweight 2D rendering. You can use this framework to handle path-based drawing, transformations, color management, and lots more.

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.

Getting Started

By the time you’ve got to the end of this tutorial, you’ll have created a graph view that looks like this:

2-ResultGraphView

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:

2-ViewHierarchy

First, download the starter project. It’s pretty much where you left off in the previous part. The only difference is that in Main.storyboard, CounterView is inside of another view (with a yellow background). Build and run, and this is what you will see:

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.

Now in Main.storyboard click the name of the yellow view in the Document Outline slowly twice to rename it, and call it Container View. Drag a new UIView from the object library to inside of Container View, below the Counter View.

Change the class of the new view to GraphView in the Identity Inspector. The only thing left is to add constraints for the new GraphView, similar to how you did it in the previous part of the tutorial:

  • With the GraphView selected, Control-drag from the center slightly left (still within the view), and choose Width from the popup menu.
  • Similarly, with the GraphView selected, Control-drag from the center slightly up (still within the view), and choose Height from the popup menu.
  • Control-drag left from inside the view to outside the view and choose Center Horizontally in Container.
  • Control-drag up from inside the view to outside the view, and choose Center Vertically in Container.

Edit the constraint constants in the Size Inspector to match these:

Your Document Outline should look like this:

Flo2-Outline

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 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:

Flo2-ConnectGraphViewOutlet

Seting up the Animated Transition

Still in Main.storyboard, drag a Tap Gesture Recognizer from the Object Library to the Container View in the Document Outline:

Flo2-AddTapGesture

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 displayed.

Now add the tap method to do the transition:

@IBAction func counterViewTap(_ gesture: UITapGestureRecognizer?) {
  if (isGraphViewShowing) {
    //hide Graph
    UIView.transition(from: graphView,
                      to: counterView,
                      duration: 1.0,
                      options: [.transitionFlipFromLeft, .showHideTransitionViews],
                      completion:nil)
  } else {
    //show Graph
    UIView.transition(from: counterView,
                      to: graphView,
                      duration: 1.0,
                      options: [.transitionFlipFromRight, .showHideTransitionViews],
                      completion: nil)
  }
  isGraphViewShowing = !isGraphViewShowing
}

UIView.transition(from:to:duration:options:completion:) performs a horizontal flip transition. Other transitions are cross dissolve, vertical flip and curl up or down. The transition uses .showHideTransitionViews constant, which means you don’t have to remove the view to prevent it from being shown once it is “hidden” in the transition.

Add this code at the end of pushButtonPressed(_:):

if isGraphViewShowing {
  counterViewTap(nil)
}

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:) method:

Flo2-TapGestureConnection

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.

2-ViewTransition

Analysis of the Graph View

2-AnalysisGraphView

Remember the Painter’s Model from Part 1? It explained that drawing with Core Graphics is done from the back of an image to the front, so you need an order in mind before you code. For Flo’s graph, that would be:

  1. Gradient background view
  2. Clipped gradient under the graph
  3. The graph line
  4. The circles for the graph points
  5. Horizontal graph lines
  6. The graph labels

Drawing a Gradient

You’ll now draw a gradient in the Graph View.

Go to GraphView.swift and replace the code with:

import UIKit

@IBDesignable class GraphView: UIView {

  // 1
  @IBInspectable var startColor: UIColor = .red
  @IBInspectable var endColor: UIColor = .green

    override func draw(_ rect: CGRect) {

      // 2
      let context = UIGraphicsGetCurrentContext()!
      let colors = [startColor.cgColor, endColor.cgColor]

      // 3
      let colorSpace = CGColorSpaceCreateDeviceRGB()

      // 4
      let colorLocations: [CGFloat] = [0.0, 1.0]

      // 5
      let gradient = CGGradient(colorsSpace: colorSpace,
                                     colors: colors as CFArray,
                                  locations: colorLocations)!

      // 6
      let startPoint = CGPoint.zero
      let endPoint = CGPoint(x: 0, y: bounds.height)
      context.drawLinearGradient(gradient,
                          start: startPoint,
                            end: endPoint,
                        options: [])
    }
}

There are a few things to go over here:

  1. 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.
  2. 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 that draw(_:) draws into.
  3. All contexts have a color space. This could be CMYK or grayscale, but here you’re using the RGB color space.
  4. 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.
  5. Create the actual gradient, defining the color space, colors and color stops.
  6. 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 gradient will fill the entire rect of draw(_:).

Set up Xcode so that you have a side-by-side view of your code and the storyboard using the Assistant Editor (Show Assistant Editor…\Counterparts\Main.storyboard), and you’ll see the gradient appear on the Graph View.

2-InitialGradient

In the storyboard, select the Graph View. Then in the Attributes Inspector, change Start Color to RGB(250, 233, 222), and End Color to RGB(252, 79, 8) (click the color, then Other\Color Sliders):

2-FirstGradient

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.

Build and run the application, and you’ll notice the graph looks a lot nicer, or at least the background of it. :]

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. Clipping areas allow you to define the area you want to be filled in, instead of the whole context.

Go to GraphView.swift.

First, add these constants at the top of GraphView, which we will use for drawing later:

private struct Constants {
  static let cornerRadiusSize = CGSize(width: 8.0, height: 8.0)
  static let margin: CGFloat = 20.0
  static let topBorder: CGFloat = 60
  static let bottomBorder: CGFloat = 50
  static let colorAlpha: CGFloat = 0.3
  static let circleDiameter: CGFloat = 5.0
}

Now add this code to the top of draw(_:):

let path = UIBezierPath(roundedRect: rect,
                  byRoundingCorners: .allCorners,
                        cornerRadii: Constants.cornerRadiusSize)
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:

2-RoundedCorners2

Note: Drawing static views with Core Graphics is generally quick enough, but if your views move around or need frequent redrawing, you should use Core Animation layers. Core Animation is optimized so that the GPU, not the CPU, handles most of the processing. In contrast, the CPU processes view drawing performed by Core Graphics in draw(_:).

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 = [4, 2, 6, 4, 5, 8, 3]

This holds sample data that represents seven days. Ignore the warning you get about changing this to a let value, as we’ll need it to be a var later on.

Add this code to the top of the draw(_:):

let width = rect.width
let height = rect.height

And add this code to the end of draw(_:):

//calculate the x point

let margin = Constants.margin
let graphWidth = width - margin * 2 - 4
let columnXPoint = { (column: Int) -> CGFloat in
  //Calculate the gap between points
  let spacing = graphWidth / CGFloat(self.graphPoints.count - 1)
  return CGFloat(column) * spacing + margin + 2
}

The x-axis points consist of 7 equally spaced points. The code above is a closure expression. It could have been added as a function, but for small calculations like this, you can 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 draw(_:):

// calculate the y point

let topBorder = Constants.topBorder
let bottomBorder = Constants.bottomBorder
let graphHeight = height - topBorder - bottomBorder
let maxValue = graphPoints.max()!
let columnYPoint = { (graphPoint: Int) -> CGFloat in
  let y = CGFloat(graphPoint) / CGFloat(maxValue) * graphHeight
  return graphHeight + topBorder - y // Flip the graph
}

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 in Core Graphics 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 draw(_:):

// draw the line graph

UIColor.white.setFill()
UIColor.white.setStroke()

// set up the points line
let graphPath = UIBezierPath()

// go to start of line
graphPath.move(to: 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.addLine(to: 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:

2-FirstGraphLine

Now that you verified the line draws correctly, remove this from the end of draw(_:):

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 draw(_:):

//Create the clipping path for the graph gradient

//1 - save the state of the context (commented out for now)
//context.saveGState()

//2 - make a copy of the path
let clippingPath = graphPath.copy() as! UIBezierPath

//3 - add lines to the copied path to complete the clip area
clippingPath.addLine(to: CGPoint(x: columnXPoint(graphPoints.count - 1), y:height))
clippingPath.addLine(to: CGPoint(x:columnXPoint(0), y:height))
clippingPath.close()

//4 - add the clipping path to the context
clippingPath.addClip()

//5 - check clipping path - temporary code
UIColor.green.setFill()
let rectPath = UIBezierPath(rect: rect)
rectPath.fill()
//end temporary code

A section-by-section breakdown of the above code:

  1. context.saveGState() is commented out for now -- you’ll come back to this in a moment once you understand what it does.
  2. Copy the plotted path to a new path that defines the area to fill with a gradient.
  3. Complete the area with the corner points and close the path. This adds the bottom-right and bottom-left points of the graph.
  4. Add the clipping path to the context. When the context is filled, only the clipped path is actually filled.
  5. Fill the context. Remember that rect is the area of the context that was passed to draw(_:).

Your Graph View in the storyboard should now look like this:

2-GraphClipping

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 draw(_:), and add this code instead:

let highestYPoint = columnYPoint(maxValue)
let graphStartPoint = CGPoint(x: margin, y: highestYPoint)
let graphEndPoint = CGPoint(x: margin, y: bounds.height)

context.drawLinearGradient(gradient, start: graphStartPoint, end: graphEndPoint, options: [])
//context.restoreGState()

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 context.restoreGState() -- you’ll remove the comments after you draw the circles for the plot points.

At the end of draw(_:), 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:

2-SecondGraphLine

Drawing the Data Points

At the end of draw(_:), add the following:

//Draw the circles on top of the graph stroke
for i in 0..<graphPoints.count {
  var point = CGPoint(x: columnXPoint(i), y: columnYPoint(graphPoints[i]))
  point.x -= Constants.circleDiameter / 2
  point.y -= Constants.circleDiameter / 2

  let circle = UIBezierPath(ovalIn: CGRect(origin: point, size: CGSize(width: Constants.circleDiameter, height: Constants.circleDiameter)))
  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.

2-GraphWithFlatCircles

Hmmm…but what's showing up in the storyboard are not nice, round circle points! What's going on?

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 context.saveGState(), 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 context.restoreGState(), the original state is taken off the stack and the context properties revert. That's why you're seeing the weird issue with your points.

Still in GraphView.swift, in draw(_:), uncomment the context.saveGState() that takes place before creating the clipping path, and uncomment the context.restoreGState() that takes place after the clipping path has been used.

By doing this, you:

  1. Push the original graphics state onto the stack with context.saveGState().
  2. Add the clipping path to a new graphics state.
  3. Draw the gradient within the clipping path.
  4. Restore the original graphics state with context.restoreGState(). This was the state before you added the clipping path.

Your graph line and circles should be much clearer now:

2-GraphWithCircles

At the end of draw(_:), add the code to draw the three horizontal lines:

//Draw horizontal graph lines on the top of everything
let linePath = UIBezierPath()

//top line
linePath.move(to: CGPoint(x: margin, y: topBorder))
linePath.addLine(to: CGPoint(x: width - margin, y: topBorder))

//center line
linePath.move(to: CGPoint(x: margin, y: graphHeight/2 + topBorder))
linePath.addLine(to: CGPoint(x: width - margin, y: graphHeight/2 + topBorder))

//bottom line
linePath.move(to: CGPoint(x: margin, y:height - bottomBorder))
linePath.addLine(to: CGPoint(x:  width - margin, y: height - bottomBorder))
let color = UIColor(white: 1.0, alpha: Constants.colorAlpha)
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.

2-GraphWithAxisLines

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:

//Label outlets
@IBOutlet weak var averageWaterDrunk: UILabel!
@IBOutlet weak var maxLabel: UILabel!
@IBOutlet weak var stackView: UIStackView!

This adds outlets for the two labels that you want to dynamically change text for (the average water drunk label, the max water drunk label), and for a StackView with day names labels.

Now go to Main.storyboard and add the following views as subviews of the Graph View:

2-LabelledGraph

  1. UILabel with text "Water Drunk"
  2. UILabel with text "Average: "
  3. UILabel with text "2", next to the average label
  4. UILabel with text "99", right aligned next to the top of the graph
  5. UILabel with text "0", right aligned to the bottom of the graph
  6. A horizontal StackView with 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.

If you have any trouble setting up those labels, check out the final project from the end of this tutorial.

Connect averageWaterDrunk, maxLabel and stackView to the corresponding views in Main.storyboard. Control-drag from View Controller to the correct label and choose the outlet from the pop up:

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.

2-GraphHidden

Go to ViewController.swift and add this method to set up the labels:

func setupGraphDisplay() {

  let maxDayIndex = stackView.arrangedSubviews.count - 1

  //  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 = "\(graphView.graphPoints.max()!)"

  //  3 - calculate average from graphPoints
  let average = graphView.graphPoints.reduce(0, +) / graphView.graphPoints.count
  averageWaterDrunk.text = "\(average)"

  // 4 - setup date formatter and calendar
  let today = Date()
  let calendar = Calendar.current

  let formatter = DateFormatter()
  formatter.setLocalizedDateFormatFromTemplate("EEEEE")

  // 5 - set up the day name labels with correct days
  for i in 0...maxDayIndex {
    if let date = calendar.date(byAdding: .day, value: -i, to: today),
      let label = stackView.arrangedSubviews[maxDayIndex - i] as? UILabel {
      label.text = formatter.string(from: date)
    }
  }
}

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:

  1. 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. :]
  2. Redraws the graph in case there are any changes to today’s data.
  3. 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.
  4. Note: This Swift Functional Programming Tutorial explains functional programming in some depth.

  5. This section sets up DateFormatter in a way that it will get first letter of the name of a day.
  6. This loop goes through all labels inside of stackView and we set text for each label from date formatter.

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!

2-GraphFinished

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:

2-Result

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:

2-LinesExpanded

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.

2-RotatedContext

The black rectangle is drawn before rotating the context, then the green one, then the red one. Two things to notice:

  1. The context is rotated at the top left (0,0)
  2. 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.

2-RotatedTranslatedContext

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 draw(_:) to add the markers to the counter:

//Counter View markers
let context = UIGraphicsGetCurrentContext()!

//1 - save original state
context.saveGState()
outlineColor.setFill()

let markerWidth: CGFloat = 5.0
let markerSize: CGFloat = 10.0

//2 - the marker rectangle positioned at the top left
let markerPath = UIBezierPath(rect: CGRect(x: -markerWidth / 2, y: 0, width: markerWidth, height: markerSize))

//3 - move top left of context to the previous center position
context.translateBy(x: rect.width / 2, y: rect.height / 2)

for i in 1...Constants.numberOfGlasses {
  //4 - save the centred context
  context.saveGState()
  //5 - calculate the rotation angle
  let angle = arcLengthPerGlass * CGFloat(i) + startAngle - .pi / 2
  //rotate and translate
  context.rotate(by: angle)
  context.translateBy(x: 0, y: rect.height / 2 - markerSize)

  //6 - fill the marker rectangle
  markerPath.fill()
  //7 - restore the centred context for the next rotate
  context.restoreGState()
}

//8 - restore the original state in case of more painting
context.restoreGState()

Here's what you've just done:

  1. Before manipulating the context’s matrix, you save the original state of the matrix.
  2. Define the position and shape of the path -- but you're not drawing it yet.
  3. Move the context so that rotation happens around the context’s original center. (Blue lines in the previous diagram.)
  4. For each mark, you first save the centered context state.
  5. Using the individual angle previously calculated, you determine the angle for each marker and rotate and translate the context.
  6. Draw the marker rectangle at the top left of the rotated and translated context.
  7. Restore the centered context’s state.
  8. 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:

2-FinalPart2

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 have any questions or comments, please join the discussion below!

The post Core Graphics Tutorial Part 2: Gradients and Contexts appeared first on Ray Wenderlich.


Viewing all articles
Browse latest Browse all 4396

Trending Articles



<script src="https://jsc.adskeeper.com/r/s/rssing.com.1596347.js" async> </script>