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?
Handling Small Arcs
Now that you've handled the round, non-circular shapes, what about those pesky short arcs that look like they're part of a huge circle? If you look at the debug drawing, the size discrepancy between the path (black box) and the fit circle is huge:
Paths that you want to recognize as a circle should at least approximate the size of the circle itself:
Fixing this should be as easy as comparing the size of the path against the size of the fit circle.
Add the following helper method to CircleGestureRecognizer.swift:
private func calculateBoundingOverlap() -> CGFloat {
// 1
let fitBoundingBox = CGRect(
x: fitResult.center.x - fitResult.radius,
y: fitResult.center.y - fitResult.radius,
width: 2 * fitResult.radius,
height: 2 * fitResult.radius)
let pathBoundingBox = CGPathGetBoundingBox(path)
// 2
let overlapRect = fitBoundingBox.rectByIntersecting(pathBoundingBox)
// 3
let overlapRectArea = overlapRect.width * overlapRect.height
let circleBoxArea = fitBoundingBox.height * fitBoundingBox.width
let percentOverlap = overlapRectArea / circleBoxArea
return percentOverlap
}
This calculates how much the user’s path overlaps the fit circle:
- Find the bounding box of the circle fit and the user’s path. This uses
CGPathGetBoundingBox
to handle the tricky math, since the touch points were also captured as part of theCGMutablePath
path variable. - Calculate the rectangle where the two paths overlap using the
rectByIntersecting
method onCGRect
- Figure out what percentage the two bounding boxes overlap as a percentage of area. This percentage will be in the 80%-100% for a good circle gesture. In the case of the short arc shape, it will be very, very tiny!
Next, modify the isCircle
check in touchesEnded(_:withEvent:)
as follows:
let percentOverlap = calculateBoundingOverlap()
isCircle = fitResult.error <= tolerance && !hasInside && percentOverlap > (1-tolerance)
Build and run your app again; only reasonable circles should pass the test. Do your worst to fool it! :]
Handling the Cancelled State
Did you notice the check for .Cancelled
above in the debug drawing section? Touches are cancelled when a system alert comes up, or the gesture recognizer is explicitly cancelled through a delegate or by disabling it mid-touch. There's not much to be done for the circle recognizer other than to update the state machine. Add the following method to CircleGestureRecognizer.swift:
override func touchesCancelled(touches: Set<NSObject>!, withEvent event: UIEvent!) {
super.touchesCancelled(touches, withEvent: event)
state = .Cancelled // forward the cancel state
}
This simply sets the state to .Cancelled
when the touches are cancelled.
Handling Other Touches
With the game running, tap the New Set. Notice anything? That’s right, the button doesn’t work! That’s because the gesture recognizer is sucking up all the taps!
There are a few ways to get the gesture recognizer to interact properly with the other controls. The primary way is to override the default behavior by using a UIGestureRecognizerDelegate
.
Open GameViewController.swift. In viewDidLoad(_:)
set the delegate of the gesture recognizer to self
:
circleRecognizer.delegate = self
Now add the following extension at the bottom of the file, to implement the delegate method:
extension GameViewController: UIGestureRecognizerDelegate {
func gestureRecognizer(gestureRecognizer: UIGestureRecognizer, shouldReceiveTouch touch: UITouch) -> Bool {
// allow button press
return !(touch.view is UIButton)
}
}
This prevents the gesture recognizer from recognizing touches over a button; this lets the touch proceed down to the button itself. There are several delegate methods, and these can be used to customize where and how a gesture recognizer works in the view hierarchy.
Build and run your app again; tap the button and it should work properly now.
Spit-Shining the Game
All that's left is to clean up the interaction and make this a well-polished game.
First, you need to prevent the user from interacting with the view after an image has been circled. Otherwise, the path will continue to update while waiting for the new set of images.
Open GameViewController.swift. Add the following code to the bottom of selectImageViewAtIndex(_:)
:
circleRecognizer.enabled = false
Now re-enable your gesture recognizer at the bottom of startNewSet(_:)
, so the next round can proceed:
circleRecognizer.enabled = true
Next, add the following to the .Began
clause in circled(_:)
:
if c.state == .Began {
circlerDrawer.clear()
goToNextTimer?.invalidate()
}
This adds a timer to automatically clear the path after a short delay so that the user is encouraged to try again.
Also in circled(_:)
, add the following code to the final state check:
if c.state == .Ended || c.state == .Failed || c.state == .Cancelled {
circlerDrawer.updateFit(c.fitResult, madeCircle: c.isCircle)
goToNextTimer = NSTimer.scheduledTimerWithTimeInterval(afterGuessTimeout, target: self, selector: "timerFired:", userInfo: nil, repeats: false)
}
This sets up a timer to fire a short time after the gesture recogniser either ends, fails or is cancelled.
Finally, add the following method to GameViewController:
func timerFired(timer: NSTimer) {
circlerDrawer.clear()
}
This clears the circle after the timer fires, so that the user is tempted to draw another to have another attempt.
Build and run your app; If the gesture doesn’t approximate a circle, you'll see that the path clears automatically after a short delay.
Where to Go From Here?
You can download the completed project from this tutorial here.
You’ve built a simple, yet powerful circle gesture recognizer for your game. You can extend these concepts further to recognize other drawn shapes, or even customize the circle fit algorithm to fit other needs.
For more details, check out Apple’s documentation on Gesture Recognizers.
If you have any questions or comments about this tutorial, feel free to join the forum discussion below!