UIGestureRecognizer Tutorial: Creating Custom Recognizers
Learn how to detect circles in this custom UIGestureRecognizer tutorial. You’ll even learn how the math works! By Michael Katz.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
UIGestureRecognizer Tutorial: Creating Custom Recognizers
30 mins
- Getting Started
- Adding a Custom Gesture Recognizer
- The Gesture Recognizer State Machine
- A Basic Tap Recognizer
- Handling Multiple Touches
- Detecting a Circle
- Checking the Points
- Drawing As You Go
- The Reset Action
- The Math
- Debugging the Draw
- Recognizing A Gesture, Not a Path
- Checking Inside
- Handling Small Arcs
- Handling the Cancelled State
- Handling Other Touches
- Spit-Shining the Game
- Where to Go From Here?
Detecting a Circle
“But, hold on a second,” you cry. “A tap does not a circle make!”
Well, if you wanna get all technical about it, a single point is a circle with a radius of 0. But that’s not what’s intended here; the user has to actually circle the image for the selection to count.
To find the circle, you’ll have to collect the points that the user moves his or her finger over and see if they form a circle.
This sounds like a perfect job for a collection.
Add the following instance variable to the top of the CircleGestureRecognizer
class:
private var touchedPoints = [CGPoint]() // point history
You’ll use this to track the points the user touched.
Now add the following method to the CircleGestureRecognizer
class:
override func touchesMoved(touches: Set<NSObject>!, withEvent event: UIEvent!) {
super.touchesMoved(touches, withEvent: event)
// 1
if state == .Failed {
return
}
// 2
let window = view?.window
if let touches = touches as? Set<UITouch>, loc = touches.first?.locationInView(window) {
// 3
touchedPoints.append(loc)
// 4
state = .Changed
}
}
touchesMoved(_:withEvent:)
fires whenever the user moves a finger after the initial touch event. Taking each numbered section in turn:
- Apple recommends you first check that the gesture hasn’t already failed; if it has, don’t continue to process the other touches. Touch events are buffered and processed serially in the event queue. If a the user moves the touch fast enough, there could be touches pending and processed after the gesture has already failed.
- To make the math easy, convert the tracked points to window coordinates. This makes it easier to track touches that don’t line up within any particular view, so the user can make a circle outside the bounds of the image, and have it still count towards selecting that image.
- Add the points to the array.
- Update the state to
.Changed
. This has the side effect of calling the target action as well.
.Changed
is the next state to add to your state machine. The gesture recognizer should transition to .Changed
every time the touches change; that is, whenever the finger is moved, added, or removed.
Here’s your new state machine with the .Changed
state added:
Now that you have all the points, how are you going to figure out if the points form a circle?
Checking the Points
To start, add the following variables to the top of the class in CircleGestureRecognizer.swift:
var fitResult = CircleResult() // information about how circle-like is the path
var tolerance: CGFloat = 0.2 // circle wiggle room
var isCircle = false
These will help you determine if the points are within tolerance for a circle.
Update touchesEnded(_:withEvent:)
so that it looks like the code below:
override func touchesEnded(touches: Set<NSObject>!, withEvent event: UIEvent!) {
super.touchesEnded(touches, withEvent: event)
// now that the user has stopped touching, figure out if the path was a circle
fitResult = fitCircle(touchedPoints)
isCircle = fitResult.error <= tolerance
state = isCircle ? .Ended : .Failed
}
This cheats a little bit as it uses a pre-made circle detector. You can take a peek at CircleFit.swift now, but I'll describe its inner workings in just a bit. The main take-away is that the detector tries to fit the traced points to a circle. The error
value is how far the path deviated from a true circle, and the tolerance
is there because you can’t expect users to draw a perfect circle. If the error is within tolerance, the recognizer moves to the .Ended
state; if the circle is out of tolerance then move to .Failed
.
If you were to build and run right now, the game wouldn’t quite work because the gesture recognizer is still treating the gesture like a tap.
Go back to GameViewController.swift, and change circled(_:)
as follows:
func circled(c: CircleGestureRecognizer) {
if c.state == .Ended {
findCircledView(c.fitResult.center)
}
}
This uses the calculated center of the circle to figure out which view was circled, instead of just getting the last point touched.
Build and run your app; try your hand at the game — pun quite intended. It’s not easy to get the app to recognize your circle, is it? What’s remaining is to bridge the difference between mathematical theory and the real world of imprecise circles.
Drawing As You Go
Since it's tough to tell exactly what's going on, you'll draw the path the user traces with their finger. iOS already comes with most of what you need in Core Graphics.
Add the following to the instance variable declarations in CircleGestureRecognizer.swift:
var path = CGPathCreateMutable() // running CGPath - helps with drawing
This provides a mutable CGPath
object for drawing the path.
Add the following to the bottom of touchesBegan(_:withEvent:)
:
let window = view?.window
if let touches = touches as? Set<UITouch>, loc = touches.first?.locationInView(window) {
CGPathMoveToPoint(path, nil, loc.x, loc.y) // start the path
}
This makes sure the path starts out in the same place that the touches do.
Now add the following to touchesMoved(_:withEvent:)
, just below touchedPoints.append(loc)
in the if let
block at the bottom:
CGPathAddLineToPoint(path, nil, loc.x, loc.y)
Whenever the touch moves, you add the new point to the path by way of a line. Don't worry about the straight line part; since the points should be very close together, this will wind up looking quite smooth once you draw the path.
In order to see the path, it has to be drawn in the game’s view. There’s already a view in the hierarchy of CircleDrawView
.
To show the path in this view, add the following to the bottom of circled(_:)
in GameViewController.swift:
if c.state == .Began {
circlerDrawer.clear()
}
if c.state == .Changed {
circlerDrawer.updatePath(c.path)
}
This clears the view when a gesture starts, and draws the path as a yellow line that follows the user's finger.
Build and run your app; try drawing on the screen to see how it works:
Cool! But did you notice anything funny when you drew a second or third circle?
Even though you added a call to circlerDrawer.clear()
when moving into the .Began
state, it appears that each time a gesture is made, the previous ones are not cleared. That can only mean one thing: it's time for a new action in your gesture recognizer state machine: reset()
.