In this tutorial we’ll be creating an app that lets you play around with shapes, moving, scaling and rotating them. We’ll be UIGestureRecognizers and UIBezierPaths. The end result will look like this:
Detecting Taps
To get started, create a new Single View Application
. Make sure to select Swift
as the programming language andUniversal
in the Devices tab. The app works best on an iPad because of the larger screen space.
We’ll want to add shapes to our view when the user taps the screen. We’ll be using an UITapGestureRecognizer for this. A gesture recognizer is an object that detects when a certain gesture is performed. UITapGestureRecognizer in particular detects when a tap occurs. Each gesture recognizer has one or more target-action pairs associated with it, whenever a gesture is recognized a method is called for each of the gesture recognizer’s targets. A gesture recognizer must also be attached to a view, the gesture recognizers will only receive events that happen within that view or its subviews.
Let’s add a UITapGestureRecognizer to our view controller’s view. The viewDidLoad
method is a good place to add gesture recognizers to a ViewController’s view. The action we provide to the gesture recognizer has to correspond to a method in the target’s class. The method’s name is passed a string with a colon(:) at the end, the colon signifies that the method takes 1 parameter, which in this case will be the gesture recognizer.
class ViewController {
...
func viewDidLoad() {
...
let tapGR = UITapGestureRecognizer(target: self, action: "didTap:")
self.view.addGestureRecognizer(tapGR)
}
func didTap(tapGR: UITapGestureRecognizer) {
}
...
}
Adding Shapes
All shapes in our app will be instances of our ShapeView
class, this class will handle the drawing and the gestures. Initially we’ll be drawing a red square but we’ll get into more complicated shapes soon!
To start off, create a new class ShapeView
that subclasses UIView.
In the didTap
method we can retrieve the tap’s location via the gesture recognizer’s locationInView:
method. Once we have a tap position we’ll want to create a new shape at that position. Let’s create an init(origin: CGPoint)
method on our ShapeView
that will create the view at a given position. We’ll also define a size
constant initialized to 150
. In our init method we call the init(frame)
method on the superclass and reposition the newly created view’s center
to be at the given origin
.
class ShapeView {
let size: CGFloat = 150.0
init(origin: CGPoint) {
super.init(frame: CGRectMake(0.0, 0.0, size, size))
self.center = origin
}
// We need to implement init(coder) to avoid compilation errors
required init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
...
}
We’ll draw a shape in our view in the drawRect:
method. We’ll be using UIBezierPath for drawing. The drawRect:
method does nothing by default, we override it to provide custom drawing logic for our view.
To draw a shape with UIBezierPath we first have to specify the geometry of our shape after with we use the fill
method to fill the shape. To set the fill color we use the setFill
method on UIColor. We’ll create our bezier path using the UIBezierPath(roundedRect:, cornerRadius:)
init method. This will create a rounded rectangle shape. Afterwards we set the fill color and fill the path.
class ShapeView {
...
override func drawRect(rect: CGRect) {
let path = UIBezierPath(roundedRect: rect, cornerRadius: 10)
UIColor.redColor().setFill()
path.fill()
}
}
Now back to the didTap
method. We’ll create ShapeViews
here and add them to the view hierarchy.
class ViewController {
...
func didTap(tapGR: UITapGestureRecognizer) {
let tapPoint = tapGR.locationInView(self.view)
let shapeView = ShapeView(origin: tapPoint)
self.view.addSubview(shapeView)
}
...
}
Tapping on the screen will now add a red rounded rectangle at the tap location.
Notice that the rectangles have a black background near the corner region, to fix this problem we’ll set the ShapeView's
backgroundColor
to UIColor.clearColor()
in the init method.
class ShapeView {
...
init(origin: CGPoint) {
...
self.backgroundColor = UIColor.clearColor()
}
...
}
Stroked rectangles
In addition to provide a method for filling a path UIBezierPath also provides a method for stroking a path i.e. adding a colored outline to the path. One important aspect of how the stroke is drawn is that half of the stroke will be on the outside of the path and half of the stroke will be on the inside.
Lets add a stroke to our rectangles, first lets define the stroke’s width as a constant on our class lineWidth
. The lineWidth can be set on a bezier path via it’s lineWidth
property.
class ShapeView {
...
let lineWidth: CGFloat = 3
...
}
Next we have to change our drawRect:
method so that it strokes our path. Currently our shape is the size of our view, that means that half of our stroke would be outside the view and hence invisible. To fix this we’ll inset the rect we use to create our bezier path by half our line width. Insetting a CGRect
is done via the CGRectInset(rect,dx,dy)
function. The rect’s origin value is offset in the x-axis by the distance specified by the dx parameter and in the y-axis by the distance specified by the dy parameter, and its size adjusted by (2dx,2dy), relative to the source rectangle. If dx and dy are positive values, then the rectangle’s size is decreased.
We’ll also have to set a color for the stroke, this is done via the setStroke
method on UIColor.
class ShapeView {
...
override func drawRect(rect: CGRect) {
let insetRect = CGRectInset(rect, lineWidth / 2, lineWidth / 2)
let path = UIBezierPath(roundedRect: insetRect, cornerRadius: 10)
UIColor.redColor().setFill()
path.fill()
path.lineWidth = self.lineWidth
UIColor.blackColor().setStroke()
path.stroke()
}
}
Our rectangles also display a stroke now!
Panning
We’ll use a UIPanGestureRecognizer to move our shapes around. A UIPanGestureRecognizer is a gesture recognizer that detects panning i.e. moving a finger across the view. You can get the translation (amount of panning) of the gesture recognizer via the translationInView
method. We’ll set the gesture recognizer’s translation back to 0 after the pan to simplify our logic.
One last thing to note is that whenever we’re interacting with a shape we’ll want to move it to the top of the view hierarchy so that it’s not covered up by some shape below it. To do this we call bringSubviewToFront(view)
on the shape’s superview
.
We’ll create a method initGestureRecognizers
where we set up our gesture recognizers. Whenever a method gets too complex and starts doing multiple things it’s good design to split it into multiple methods. We’ll initialize ourUIPanGestureRecognizer just like we did we the UITapGestureRecognizer. In the target method we’ll implement the logic described above, effectively moving the view by adding the translation to it’s center
property.
class ShapeView: UIView {
...
init(origin: CGPoint) {
...
initGestureRecognizers()
}
func initGestureRecognizers() {
let panGR = UIPanGestureRecognizer(target: self, action: "didPan:")
addGestureRecognizer(panGR)
}
func didPan(panGR: UIPanGestureRecognizer) {
self.superview!.bringSubviewToFront(self)
var translation = panGR.translationInView(self)
self.center.x += translation.x
self.center.y += translation.y
panGR.setTranslation(CGPointZero, inView: self)
}
...
}
We can move shapes around now!
Pinching
We’ll use a UIPinchGestureRecognizer to scale our shapes. A UIPinchGestureRecognizer is a gesture recognizer that detects pinching. When the user moves the two fingers toward each other, the meaning is zoom-out; when the user moves the two fingers away from each other, the meaning is zoom-in. You can get the scale of the gesture recognizer via the scale
property. We’ll set the gesture recognizer’s scale back to 1 after the pinch to simplify our logic.
To scale a view we’ll modify the view’s transform
property. The transform
property is of type CGAffineTransform and can be used to modify a view’s scale, rotation, translation and more. See this for more details on affine transforms.
A view’s transform property is initially equal to CGAffineTransformIdentity
with is the do nothing
transform. We can use various methods to create a new transform or modify an existing one. In our case the best way is to modify the view’s transform via the CGAffineTransformScale(transform,scaleX,scaleY)
function which takes a transform and multiplies its scale factor by scaleX
in the horizontal direction and scaleY
in the vertical direction. scaleX
will be equal to scaleY
and to the gesture recognizer’s scale
property.
class ShapeView: UIView {
...
func initGestureRecognizers() {
let panGR = UIPanGestureRecognizer(target: self, action: "didPan:")
addGestureRecognizer(panGR)
let pinchGR = UIPinchGestureRecognizer(target: self, action: "didPinch:")
addGestureRecognizer(pinchGR)
}
...
func didPinch(pinchGR: UIPinchGestureRecognizer) {
self.superview!.bringSubviewToFront(self)
let scale = pinchGR.scale
self.transform = CGAffineTransformScale(self.transform, scale, scale)
pinchGR.scale = 1.0
}
...
}
We can scale shapes now!
Rotating
We’ll use a UIRotationGestureRecognizer to rotate our shapes. A UIRotationGestureRecognizer is a gesture recognizer that detects rotation. The gesture is triggered when the user moves the fingers opposite each other in a circular motion. You can get the rotation of the gesture recognizer via the rotation
property. We’ll set the gesture recognizer’s rotation back to 0 after the rotation to simplify our logic. Rotation is given in radians.
To update the rotation of our view we’ll again modify the view’s transform
property. We’ll use theCGAffineTransformRotate(transform,rotation)
function to modify our view’s transform. The CGAffineTransformRotate
function takes a transform and produces a new transform by adding rotation
to the transforms rotation
factor.
class ShapeView: UIView {
...
func initGestureRecognizers() {
let panGR = UIPanGestureRecognizer(target: self, action: "didPan:")
addGestureRecognizer(panGR)
let pinchGR = UIPinchGestureRecognizer(target: self, action: "didPinch:")
addGestureRecognizer(pinchGR)
let rotationGR = UIRotationGestureRecognizer(target: self, action: "didRotate:")
addGestureRecognizer(rotationGR)
}
...
func didRotate(rotationGR: UIRotationGestureRecognizer) {
self.superview!.bringSubviewToFront(self)
let rotation = rotationGR.rotation
self.transform = CGAffineTransformRotate(self.transform, rotation)
rotationGR.rotation = 0.0
}
...
}
We can rotate shapes now!
Rotating and pinching a shape works fine now but we broke translation (When we try to move a scaled / rotated shape the movement won’t work correctly). There are multiple ways to fix it but easiest is to apply the affine transform of our view to the translation returned by the pan gesture recognizer.
class ShapeView {
...
func didPan(panGR: UIPanGestureRecognizer) {
self.superview!.bringSubviewToFront(self)
var translation = panGR.translationInView(self)
translation = CGPointApplyAffineTransform(translation, self.transform)
self.center.x += translation.x
self.center.y += translation.y
panGR.setTranslation(CGPointZero, inView: self)
}
...
}
All gestures should work fine now.
Random Colors
Lets improve the appearance of our rectangles by coloring them with a random color instead of red. We’ll create a random color using UIColor’s initWithHue:saturation:brightness:alpha: constructor.
The UIColor initWithHue:saturation:brightness:alpha: constructor creates a new color instance by specifying it’s hue, saturation, brightness and alpha values. All these values have to be floating point numbers between 0 and 1.
To get a feeling for how these numbers affect the resulting color open up a color picker and play with the sliders.
The above dark orange color can be created in code via:
UIColor(hue: 25.0 / 359.0, saturation: 0.8, brightness: 0.7, alpha: 1.0)
We’ll write a method that returns a random colors. First we’ll have to generate a random float between 0 and 1, this can be done via CGFloat(Float(arc4random()) / Float(UINT32_MAX))
, arc4random()
returns a value between 0
andUINT32_MAX
so dividing that value by UINT32_MAX
will give us a value between 0 and 1. For saturation and brightness we choose some arbitrary values. We’ll also make the alpha value 0.8, so that the shapes will be transparent.
class ShapeView {
...
func randomColor() -> UIColor {
let hue:CGFloat = CGFloat(Float(arc4random()) / Float(UINT32_MAX))
return UIColor(hue: hue, saturation: 0.8, brightness: 1.0, alpha: 0.8)
}
}
We’ll create a new property fillColor
on ShapeView
that we’ll initialize with a random color, we also have to modify the drawRect
method to use our fillColor
instead of UIColor.redColor()
.
class ShapeView {
...
var fillColor: UIColor!
init(origin: CGPoint) {
super.init(frame: CGRectMake(0.0, 0.0, size, size))
self.fillColor = randomColor()
...
}
...
override func drawRect(rect: CGRect) {
...
self.fillColor.setFill()
...
}
...
}
Our rectangles will now appear slightly transparent with random colors.
More Shapes
It’s easy to add more type of shapes now that we have this going. Let’s add a circle and rectangle shape. We’re currently creating our path object in the drawRect:
method. Let’s make path
a property of the ShapeView
class and initialize in the init method. For this we’ll write a method randomPath() -> UIBezierPath
that returns a random path (a rectangle, a circle or a triangle).
The circle is easy to create, we just call the UIBezierPath(ovalInRect)
constructor, the triangle is a bit trickier to create, you can find the exact details below.
class ShapeView {
...
var path: UIBezierPath!
...
func randomPath() -> UIBezierPath {
let insetRect = CGRectInset(self.bounds,lineWidth,lineWidth)
let shapeType = arc4random() % 3
if shapeType == 0 {
return UIBezierPath(roundedRect: insetRect, cornerRadius: 10.0)
}
if shapeType == 1 {
return UIBezierPath(ovalInRect: insetRect)
}
return trianglePathInRect(insetRect)
}
init(origin: CGPoint) {
...
self.path = randomPath()
...
}
override func drawRect(rect: CGRect) {
self.fillColor.setFill()
self.path.fill()
self.path.lineWidth = self.lineWidth
UIColor.blackColor().setStroke()
self.path.stroke()
}
}
A triangle is a polygon with 3 vertices. UIBezierPath doesn’t have a constructor for creating a triangle but it has several methods that allow us to construct arbitrary polygons. To add a polygon to a bezier path you first have to add the first point via the moveToPoint(point)
method. Lines can then be added to subsequent points via theaddLineToPoint(point)
method. Once all the points are added, the polygon can be closed via the closePath
method. This method connects the last added point to the initial point (specified in moveToPoint
).
The 3 points on our triangle are: the midpoint at the top, the bottom right point and the bottom left point. We’ll create a method trianglePathInRect(rect)
that returns a triangle path. This method is called in randomPath()
.
class ShapeView {
...
func trianglePathInRect(rect:CGRect) -> UIBezierPath {
let path = UIBezierPath()
path.moveToPoint(CGPointMake(rect.width / 2.0, rect.origin.y))
path.addLineToPoint(CGPointMake(rect.width,rect.height))
path.addLineToPoint(CGPointMake(rect.origin.x,rect.height))
path.closePath()
return path
}
...
}
Now instead of just rectangles we get a random path each time we tap the screen.
Challenges
1 . Hatching
Add a hatching texture to the shapes. You can download 2 hatching images here. Hatching can be done by first filling the path with the fillColor and afterwards filling the path with a pattern image. You can create a color from an image using the UIColor(patternImage) constructor. Choose randomly between the 2 hatching images. Also randomly draw a shape with a hatching or not.
class ShapeView {
override func drawRect(rect: CGRect) {
self.fillColor.setFill()
self.path.fill()
var name = "hatch"
if arc4random() % 2 == 0 {
name = "cross-hatch"
}
let color = UIColor(patternImage: UIImage(named: name)!)
color.setFill()
if arc4random() % 2 == 0 {
path.fill()
}
UIColor.blackColor().setStroke()
path.lineWidth = self.lineWidth
path.stroke()
}
}
2 . Regular Polygons
Add a new shape. A regular polygon with a random number of vertices between 3..12. A regular polygon is a polygon that is equiangular (all angles are equal in measure) and equilateral (all sides have the same length).
You’ll have to connect N points on a circle. The angular offset between the points is constant and equal to 2 * PI / N. Create a method that returns a point on a circle at a certain, angle, radius and offset.
func pointFrom(angle: CGFloat, radius: CGFloat, offset: CGPoint) -> CGPoint {
return CGPointMake(radius * cos(angle) + offset.x, radius * sin(angle) + offset.y)
}
func regularPolygonInRect(rect:CGRect) -> UIBezierPath {
let degree = arc4random() % 10 + 3
let path = UIBezierPath()
let center = CGPointMake(rect.width / 2.0, rect.height / 2.0)
var angle:CGFloat = -CGFloat(M_PI / 2.0)
let angleIncrement = CGFloat(M_PI * 2.0 / Double(degree))
let radius = rect.width / 2.0
path.moveToPoint(pointFrom(angle, radius: radius, offset: center))
for i in 1...degree - 1 {
angle += angleIncrement
path.addLineToPoint(pointFrom(angle, radius: radius, offset: center))
}
path.closePath()
return path
}
3 . Stars
Add a new star shaped path. It’s fun to create the star with a random number of corners 5…15 for example. The stars you end up with should look like this:
The problem of drawing a star is similar to drawing a regular polygon. First consider all the vertices on a regular polygon. Next for each pair of points consider a new point that has an angle halfway between them but has a lower radius. Connect all these points in order of their angle and you’ll end up with a star.
func starPathInRect(rect: CGRect) -> UIBezierPath {
let path = UIBezierPath()
let starExtrusion:CGFloat = 30.0
let center = CGPointMake(rect.width / 2.0, rect.height / 2.0)
let pointsOnStar = 5 + arc4random() % 10
var angle:CGFloat = -CGFloat(M_PI / 2.0)
let angleIncrement = CGFloat(M_PI * 2.0 / Double(pointsOnStar))
let radius = rect.width / 2.0
var firstPoint = true
for i in 1...pointsOnStar {
let point = pointFrom(angle, radius: radius, offset: center)
let nextPoint = pointFrom(angle + angleIncrement, radius: radius, offset: center)
let midPoint = pointFrom(angle + angleIncrement / 2.0, radius: starExtrusion, offset: center)
if firstPoint {
firstPoint = false
path.moveToPoint(point)
}
path.addLineToPoint(midPoint)
path.addLineToPoint(nextPoint)
angle += angleIncrement
}
path.closePath()
return path
}
4 . We❤Fractals
Add a simple fractal shape to the app. The shape is created as follows:
Step 1: Add a circle at some position
Step 2: Add a circle at each of the vertices of a regular polygon inscribed in that circle
Step 3: Repeat step 2 for each circle.
Iterating this algorithm 6 times gives us a shape that looks similar to Sierpinski’s triangle
func addDetailToFractalPath(center: CGPoint, radius: CGFloat, path:UIBezierPath, iterations:Int) {
if iterations == 0 {
return
}
var angle:CGFloat = -CGFloat(M_PI / 2.0)
let angleIncrement = CGFloat(M_PI * 2.0 / Double(3))
path.appendPath(UIBezierPath(ovalInRect: CGRectMake(center.x - radius, center.y - radius, radius * 2, radius * 2)))
for i in 1...3 {
let point = pointFrom(angle, radius: radius, offset: center)
addDetailToFractalPath(point, radius: radius / 2.0, path: path, iterations: iterations - 1)
angle += angleIncrement
}
path.closePath()
}
func fractalPathInRect(rect: CGRect) -> UIBezierPath {
let path: UIBezierPath = UIBezierPath()
addDetailToFractalPath(CGPointMake(rect.size.width / 2, rect.size.height / 2), radius: 35.0, path: path, iterations: 5)
return path
}
Love this tutorial. I was wondering if there is a way to detect a using drawing certain objects. For example tell the user to draw a circle and then press a button. The button will detect if a circle is drawn in the area.
Hi, I’ve been thinking about this problem yesterday :), I’m considering writing a tutorial on the topic.
The problem of recognizing general shapes is pretty complex. What you want to do is implement a custom UIGestureRecognizer and record all of the touch points that occur between the TouchesBegan and TouchedEnded states. Once you have all of those touches it’s time to analyze them. I think you can get away with some heuristics for simple shapes. For example to detect a circle you could take the center of gravity of all the touches and compute the distance from each touch to the center of gravity. Now depending on these distances you can classify the object as being a circle. The touches should have the same distance to the center of gravity up to some error. Also the touches should be uniformly distributed in terms of their angle to the center of gravity.
Note that I haven’t implemented this, so it’s mostly guess work at this point.
Another approach would be to use a neural network. I think this approach will give better results than the above heuristic. Neural networks can detect a vast number of shapes, you just have to feed the neural network data and it will figure out how to do the shape classification.
Hi,
Can I use it to draw a big number of circles? I want to represent boreholes in a terrain (topview), but in general I have 400 or 500 boreholes… my ideia is draw inside a view circles (proportionally), but I don’t know if the quantity of shapes can make the app be slow.
regards
Yup, this approach can definitely handle 500+ circles on a modern device. If you’re also targeting older iPads chances are you’ll run into performance issues.
Thank you so much! This has been extremely helpful to me.
hi, I’ m early in the tutorial, where I’ve just added the pinch gesture recognizer… There is an odd bug, where if I resize the red square, then try to pan it, the panning no longer matches the object. it’s as if the scale didn’t apply to the pans, so if i repeatedly resize it, the pans get to be quite disassociated with the object.
for example if i make it much larger, then try to pan it, it seems to follow my pan at a proportion to 1/scale… what might I have done wrong?
well… i figured out that the translation needs to be based on the superview of the shape view…. but i’ve come across several other bugs while I was at it… if you pinch to zoom, the center of the pinch is not where your fingers are, likely because it’s based on the coordinate system of the view and not the superview again… that can get really bad when it’s been made a large size. also, when you make the object very large, it’s totally pixelated… that’s because it’s not actually redrawing and re-laying out the objects… it’s just drawing small and scaling the bitmap… oh well… i guess technically the tutorial is about fixing those kinds of bugs…