来源:点击打开链接
Custom gesture recognizers delight users by making apps feel unique and alive. If basic taps, pans, and rotations are the utility and pickup trucks of the iOS world, custom gesture recognizers are the flashy hot rods with custom paint jobs and hydraulics. Read this custom UIGestureRecognizer tutorial and learn all about gesture recognizers!
In this tutorial you’ll take a fun little “find the differences” game and make it interactive by adding a custom circle gesture recognizer to select the non-matching image. Along the way you’ll learn:
- How to use
UIGestureRecognizer
subclasses to leverage the provided state machine and callback mechanism to simplify gesture detection. - How to fit a circle to a collection of touched points.
- How to be “fuzzy” in recognizing specific shapes, as drawing with one’s finger is often imprecise.
Note: This gesture recognizer tutorial assumes you already have general knowledge of how gesture recognizers work and how to use a pre-defined gesture recognizer in your app. To get up to speed, read through this UIGestureRecognizer on this site.
Getting Started
MatchItUp is a simple game that shows four images to the user, three alike and one image that is slightly different than the others. The user’s job is to identify the odd one out by drawing a circle over it with a finger:
Download and open the starter project for this tutorial here.
Build and run your app; you’ll see four images, but you can’t select the odd one out yet. Your task is to add a custom gesture recognizer to this game. The custom gesture recognizer will detect when the user draws a circle around an image. If they draw around the odd one out, they win!
Adding a Custom Gesture Recognizer
Go to File\New\File… and select the iOS\Source\Cocoa Touch Class template to create a class calledCircleGestureRecognizer as a subclass of UIGestureRecognizer. Make sure Swift is selected. Then clickNext and then Create.
In order for a gesture recognizer to work, it has to be attached to a view in the responder chain. When a user taps on the screen, the touch event is forwarded along the stack of views with each view’s gesture recognizers receiving a chance to handle those touches.
Open GameViewController.swift and add an instance variable for your gesture recognizer:
var circleRecognizer: CircleGestureRecognizer! |
Next, add the following code to the bottom of viewDidLoad()
:
circleRecognizer = CircleGestureRecognizer(target: self, action: "circled:") view.addGestureRecognizer(circleRecognizer) |
This creates the gesture recognizer and adds it to the main view.
But wait… if the goal is to have the user circle the differing image, why not add the recognizer to each image view instead of the main view?
That’s a great question — glad you asked! :]
When building gesture recognizers, you must compensate for the imprecise nature of the user interface. If you’ve ever tried to sign your name inside a little box on a touchscreen, you’ll know what I mean! :]
When you put the recognizer on the whole view, it’s more forgiving to users who start or continue a gesture slightly outside the bounds of the image’s box. Eventually, your recognizer will have a tolerance setting to help those who can’t draw a perfect circle.
Build and run your app; even though you’ve created a subclass of UIGestureRecognizer
, you haven’t added any code yet so it will recognize… exactly zero gestures! To make it useful, your gesture recognizer needs to implement a gesture recognizer state machine.
The Gesture Recognizer State Machine
The simplest gesture in a user’s repertoire is a tap; the user puts a finger down and then lifts it up. There are two methods called on the gesture recognizer for this event: touchesBegan(_:withEvent:)
andtouchesEnded(_:withEvent:)
.
In the case of a simple tap gesture, these methods correspond to the gesture recognizer states .Began
and.Ended
:
A Basic Tap Recognizer
To see this in action, you’ll implement this state machine in the CircleGestureRecognizer
class.
First things first! Add the following import at the top of CircleGestureRecognizer.swift:
import UIKit.UIGestureRecognizerSubclass |
UIGestureRecognizerSubclass
is a public header in UIKit
, but isn’t included in the umbrella UIKit
header. Importing this is necessary since you will need it to update the state
property which, otherwise, would be a read-only property in UIGestureRecognizer
.
Now add the following code to the same class:
override func touchesBegan(touches: Set<NSObject>!, withEvent event: UIEvent!) { super.touchesBegan(touches, withEvent: event) state = .Began } override func touchesEnded(touches: Set<NSObject>!, withEvent event: UIEvent!) { super.touchesEnded(touches, withEvent: event) state = .Ended } |
If you ran the app now and tapped the screen, the app would crash since you are not handling the gesture yet.
Add the following to the class in GameViewController.swift:
func circled(c: CircleGestureRecognizer) { if c.state == .Ended { let center = c.locationInView(view) findCircledView(center) } } |
The gesture recognizer’s target-action is fired when there’s a state change in the gesture recognizer. When the fingers touch the screen, touchesBegan(_:withEvent)
fires. The gesture recognizer sets its state to .Began
, resulting in an automatic call to the target-action. When the fingers are removed, touchesEnded(_:withEvent)
sets the state to .Ended
, calling the target-action again.
Earlier, when you set up the gesture recognizer, you made the target-action the circled(_:)
method. The implementation of this method uses the provided findCircledView(_:)
to check which image was tapped.
Build and run your app; tap one of the images to select it. The game checks your response and moves you to the next round:
Handling Multiple Touches
So you have a working tap gesture recognizer, right? Not so fast, fancy fingers! :] Note that the methods are named with “touches” — plural. Gesture recognizers can detect multi-finger gestures, but the game’s circle recognizer is meant to just recognize a single-figure gesture.
You’ll need a check that there’s only a single finger involved in the touch.
Open CircleGestureRecognizer.swift and modify touchesBegan(_:)
so that the touches
Set parameter will allow one UITouch
per finger:
override func touchesBegan(touches: Set<NSObject>!, withEvent event: UIEvent!) { super.touchesBegan(touches, withEvent: event) if touches.count != 1 { state = .Failed } state = .Began } |
Here you’ve introduced a third state: .Failed
. .Ended
indicates that the gesture completed successfully , while the .Failed
state indicates that the user’s gesture wasn’t what you expected.
It’s important that you quickly move the state machine to a terminal state, such as .Failed
, so that other gesture recognizers waiting in the wings get a chance to interpret the touches instead.
Build and run your app again; try some multi-finger taps and some single-finger taps. This time, only a single-finger tap should work to select the image.
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 ofCircleDrawView
.
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()
.
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! :]
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!