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.

Leave a rating/review
Save for later
Share
You are currently viewing page 3 of 4 of this article. Click here to view the first page.

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 being recognized as a circle

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  \sqrt{(x - x_c)^2 + (y - y_c)^2} = r^2 . 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 (x_c, y_c) and the radius (r):

Circle centered at xc, yc

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:

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

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

  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  \sqrt{(x - x_c)^2 + (y - y_c)^2} = r^2 where x_c, y_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.

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.

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.

And they say math is hard! Pshaw!

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

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.

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! :]