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?
The Reset Action
You'll call reset()
after touchesEnded
and before touchesBegan
. This gives the gesture recognizer a chance to clean up its state and start fresh.
Add the following method to CircleGestureRecognizer.swift:
override func reset() {
super.reset()
touchedPoints.removeAll(keepCapacity: true)
path = CGPathCreateMutable()
isCircle = false
state = .Possible
}
Here you clear the collection of touch points and set path
to a new path. Also, you reset the state to .Possible
, which means either that the touches haven’t matched or that the gesture has failed.
Your new state machine looks like the following:
Build and run your app again; this time, the view (and the gesture recognizer state) will be cleared between each touch.
The Math
What's going on inside CircleFit
, and why does it sometimes recognize weird shapes like lines, C’s, or S’s as circles?
Remember from high school that the equation for a circle is . If the user traced a circle, then all the points touched will fit this equation exactly:
Or more precisely, since the recognizer wants to figure out any circle, and not just one centered on the origin, the equation is . When the gesture ends, all you have is the collection of points, which is just the x’s and y’s. What’s left to figure out is the center and the radius :
There are a few methods for figuring this out, but this tutorial uses a method adapted from Nikolai Chernov’s C++ implementation of a Taubin fit. It works as follows:
- First, you average all the points together to guess at the centroid of a circle (the mean of all the x and y coordinates). If it’s a true circle, then the centroid of all the points will be the center of the circle. If the points aren’t a true circle, then the calculated center will be somewhat off:
- Next you calculate the moment. Imagine there is a mass at the center of the circle. The moment is a measure of how much each point in the touched path pulls at that mass.
- You then plug the moment value into a characteristic polynomial, the roots of which are used to find the "true center". The moment is also used to calculate the radius. The mathematical theory is beyond the scope of the tutorial, but the main idea is that this is mathematical way to solve for where , , and should be the same value for all the points.
- Finally, you calcuate a root-mean-square error as the fit. This is a measure of how much the actual points deviate from a circle:
Does your brain hurt yet? The TLDR is that the algorithm tries to fit a circle at the center of all the points, and each point pulls out the radius according to how far it is from the computed center. Then you calculate the error value according to how far removed each point is from the calculated circle. If that error is small, then you assume you have a circle.
The algorithm trips up when the points either form symmetrical round shapes, such as C's, and S's where the calculated error is small, or form short arcs or lines where the points are assumed be a small arc on a much, much larger circle.
Debugging the Draw
So to figure out what's going on with the weird gestures, you can draw the fit circle on the screen.
In CircleDrawView.swift, set the value of drawDebug
to true
:
var drawDebug = true // set to true show additional information about the fit
This draws some additional info about the fit circle to the screen.
Update the view with the fit details by adding the following clause to circled(_:)
in GameViewController.swift:
if c.state == .Ended || c.state == .Failed || c.state == .Cancelled {
circlerDrawer.updateFit(c.fitResult, madeCircle: c.isCircle)
}
Build and run your app again; draw a circular path and when you lift your finger, the fit circle will be drawn on the screen, green if the fit was successful and red if the fit failed:.
You'll learn what the other squares do in just a bit.
Recognizing A Gesture, Not a Path
Going back to the misidentified shapes, how should these not-really-circular gestures be handled? The fit is obviously wrong in two cases: when the shape has points in the middle of the circle, and when the shape isn't a complete circle.
Checking Inside
With false positive shapes like S’s, swirls, figure-8’s, etc. the fit has a low error, but is obviously not a circle. This is the difference between mathematical approximation and getting a usable gesture. One obvious fix is to exclude any paths with points in the middle of the circle.
You can solve this by checking the touched points to see if any are inside the fit circle.
Add the following helper method to CircleGestureRecognizer.swift:
private func anyPointsInTheMiddle() -> Bool {
// 1
let fitInnerRadius = fitResult.radius / sqrt(2) * tolerance
// 2
let innerBox = CGRect(
x: fitResult.center.x - fitInnerRadius,
y: fitResult.center.y - fitInnerRadius,
width: 2 * fitInnerRadius,
height: 2 * fitInnerRadius)
// 3
var hasInside = false
for point in touchedPoints {
if innerBox.contains(point) {
hasInside = true
break
}
}
return hasInside
}
This checks an exclusion zone which is a smaller rectangle that fits inside the circle. If there are any points in this square then the gesture fails. The above code does the following:
- Calculates a smaller exclusion zone. The tolerance variable will provide enough space for a reasonable, but messy circle, but still have enough room to exclude any obviously non-circle shapes with points in the middle.
- To simplify the amount of code required, this constructs a smaller square centered on the circle.
- This loops over the points and checks if the point is contained within
innerBox
.
Next, modify touchesEnded(_:withEvent:
) to add the following check to the isCircle
criteria:
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)
// make sure there are no points in the middle of the circle
let hasInside = anyPointsInTheMiddle()
isCircle = fitResult.error <= tolerance && !hasInside
state = isCircle ? .Ended : .Failed
}
This uses the check to see if there are any points in the middle of the circle. If so, then the circle is not detected.
Build and run. Try drawing an 'S' shape. You should find that you can't now. Great! :]