UIGestureRecognizer Tutorial: Creating Custom Recognizers

来源:点击打开链接

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:

The MatchItUp! game

The MatchItUp! game

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:

Basic Two-State Machine

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:

Tap an image to choose it

Tap an image to choose it

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.

Almost got away with it...

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.

Gesture State Machine 2

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:

  1. 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.
  2. 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.
  3. Add the points to the array.
  4. 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 .Changedevery 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:

state machine with .Changed added

Now that you have all the points, how are you going to figure out if the points form a circle?

all the points

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 .Endedstate; 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.

nowork

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 letblock 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:

Now draws the path

whoah

Cool! But did you notice anything funny when you drew a second or third circle?

iOS Simulator Screen Shot 1 Jun 2015 21.09.19

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:

State machine with .Possible

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?

Just a little line is recognized as a circle

Just a little line is being recognized as a circle

Remember from high school that the equation for a circle is  \sqrt{x^2 + y^2} = r^2 . If the user traced a circle, then all the points touched will fit this equation exactly:

Circle

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 :

Circle centered at xc, yc

Circle centered at xc, yc

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:

  1. 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:

    The center of the circle is guessed at the start to be the mean of all the points.

    The center of the circle is guessed at the start to be the mean of all the points.

  2. 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.
  3. 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 x_cy_c, and r should be the same value for all the points.
  4. 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:

    The blue bars represent the error, or difference between the points and red circle fit.

    The blue bars represent the error, or difference between the points and red circle fit.

Professor Rageface

And they say math is hard! Pshaw!

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.

Most of the points are on a circle, and the other points are symmetric enough to "cancel each other out."

Most of the points are on a circle, and the other points are symmetric enough to “cancel each other out.”


Here the line fits to a circle, since the points look like an arc.

Here the line fits to a circle, since the points look like an arc.

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:.

not_a_circlebad_circle

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:

  1. 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.
  2. To simplify the amount of code required, this constructs a smaller square centered on the circle.
  3. 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:

small_arc

Paths that you want to recognize as a circle should at least approximate the size of the circle itself:

matching_circle

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:

  1. 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 the CGMutablePath path variable.
  2. Calculate the rectangle where the two paths overlap using the rectByIntersecting method on CGRect
  3. 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! :]

detect_small_arc_as_bad

awhyeah

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!

no_button_worky

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!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值