Smooth Signatures

July 27, 2010

Capturing a signature during a card payment increases security and decreases processing fees. When you pay with Square, instead of signing a receipt with a pen, you sign the screen with your finger:

Tip: Shake the phone to clear the signature.

The signature shows up on the email receipt and helps Square detect and prevent fraud.

When implementing the Android client, we started off with the simplest thing that could possibly work: a custom View that captures touch events and adds the points to a path.

public class SignatureView extends View {
  private Paint paint = new Paint();
  private Path path = new Path();

  public SignatureView(Context context, AttributeSet attrs) {
    super(context, attrs);

    paint.setAntiAlias(true);
    paint.setColor(Color.BLACK);
    paint.setStyle(Paint.Style.STROKE);
    paint.setStrokeJoin(Paint.Join.ROUND);
    paint.setStrokeWidth(5f);
  }

  @Override
  protected void onDraw(Canvas canvas) {
    canvas.drawPath(path, paint);
  }

  @Override
  public boolean onTouchEvent(MotionEvent event) {
    float eventX = event.getX();
    float eventY = event.getY();

    switch (event.getAction()) {
      case MotionEvent.ACTION_DOWN:
        path.moveTo(eventX, eventY);
        return true;
      case MotionEvent.ACTION_MOVE:
      case MotionEvent.ACTION_UP:
        path.lineTo(eventX, eventY);
        break;
      default:
        return false;
    }

    // Schedules a repaint.
    invalidate();
    return true;
  }
}

While simple to implement, this approach left a lot to be desired. The signature was jagged and the user interface unresponsive:

Signature (Before)

We addressed these problems in two different ways.

Missing Events

Our custom view wasn't keeping up with our finger. At first, we worried that:

  1. Android sampled the touch screen at too low of a rate, or
  2. drawing blocked touch screen sampling.

Luckily, neither case turned out to be true. We soon discovered that Android batches touch events. Each MotionEvent delivered to onTouchEvent() contains several coordinates captured since the last time onTouchEvent() was called. To draw a signature smoothly, we need to include all of those points.

The following MotionEvent methods expose the array of coordinates:

Let's update SignatureView to incorporate the intermediate points:

public class SignatureView extends View {
  public boolean onTouchEvent(MotionEvent event) {
    ...
    switch (event.getAction()) {
      case MotionEvent.ACTION_MOVE:
      case MotionEvent.ACTION_UP:

        // When the hardware tracks events faster than they are delivered,
        // the event will contain a history of those skipped points.
        int historySize = event.getHistorySize();
        for (int i = 0; i < historySize; i++) {
          float historicalX = event.getHistoricalX(i);
          float historicalY = event.getHistoricalY(i);
          path.lineTo(historicalX, historicalY);
        }

        // After replaying history, connect the line to the touch point.
        path.lineTo(eventX, eventY);
        break;
    ...
  }
}

This simple change yields a vast improvement to the signature's appearance,
but responsiveness still suffers.

Surgically Invalidate

For each call to onTouchEvent(), SignatureView draws line segments between the touch coordinates and invalidates the entire view. SignatureView instructs Android to repaint the entire view even if a small percentage of the pixels changed.

Repainting the entire view is slow and unneccesary. Using View.invalidate(Rect) to selectively
invalidate rectangles around the most recently added line segments dramatically improves performance.

The algorithm goes like this:

  1. Create a rectangle representing the dirty region.
  2. Set the points for the four corners to the X and Y coordinates from the ACTION_DOWN event.
  3. For ACTION_MOVE and ACTION_UP, expand the rectangle to encompass the new points. (Don't forget the historical coordinates!)
  4. Pass just the dirty rectangle to invalidate(). Android won't redraw the rest.

The improvement to responsiveness after this change is immediately evident.

Fin

Utilizing the intermediate touch events makes the signature look much smoother and more realistic. Improving drawing performance by avoiding unnecessary work increases the redraw rate and makes signing feel much more responsive.

Here's the end result:

Signature (After)

And here's the final code, minus some ancillary features like shake detection:

public class SignatureView extends View {

  private static final float STROKE_WIDTH = 5f;

  /** Need to track this so the dirty region can accommodate the stroke. **/
  private static final float HALF_STROKE_WIDTH = STROKE_WIDTH / 2;

  private Paint paint = new Paint();
  private Path path = new Path();

  /**
   * Optimizes painting by invalidating the smallest possible area.
   */
  private float lastTouchX;
  private float lastTouchY;
  private final RectF dirtyRect = new RectF();

  public SignatureView(Context context, AttributeSet attrs) {
    super(context, attrs);

    paint.setAntiAlias(true);
    paint.setColor(Color.BLACK);
    paint.setStyle(Paint.Style.STROKE);
    paint.setStrokeJoin(Paint.Join.ROUND);
    paint.setStrokeWidth(STROKE_WIDTH);
  }

  /**
   * Erases the signature.
   */
  public void clear() {
    path.reset();

    // Repaints the entire view.
    invalidate();
  }

  @Override
  protected void onDraw(Canvas canvas) {
    canvas.drawPath(path, paint);
  }

  @Override
  public boolean onTouchEvent(MotionEvent event) {
    float eventX = event.getX();
    float eventY = event.getY();

    switch (event.getAction()) {
      case MotionEvent.ACTION_DOWN:
        path.moveTo(eventX, eventY);
        lastTouchX = eventX;
        lastTouchY = eventY;
        // There is no end point yet, so don't waste cycles invalidating.
        return true;

      case MotionEvent.ACTION_MOVE:
      case MotionEvent.ACTION_UP:
        // Start tracking the dirty region.
        resetDirtyRect(eventX, eventY);

        // When the hardware tracks events faster than they are delivered, the
        // event will contain a history of those skipped points.
        int historySize = event.getHistorySize();
        for (int i = 0; i < historySize; i++) {
          float historicalX = event.getHistoricalX(i);
          float historicalY = event.getHistoricalY(i);
          expandDirtyRect(historicalX, historicalY);
          path.lineTo(historicalX, historicalY);
        }

        // After replaying history, connect the line to the touch point.
        path.lineTo(eventX, eventY);
        break;

      default:
        debug("Ignored touch event: " + event.toString());
        return false;
    }

    // Include half the stroke width to avoid clipping.
    invalidate(
        (int) (dirtyRect.left - HALF_STROKE_WIDTH),
        (int) (dirtyRect.top - HALF_STROKE_WIDTH),
        (int) (dirtyRect.right + HALF_STROKE_WIDTH),
        (int) (dirtyRect.bottom + HALF_STROKE_WIDTH));

    lastTouchX = eventX;
    lastTouchY = eventY;

    return true;
  }

  /**
   * Called when replaying history to ensure the dirty region includes all
   * points.
   */
  private void expandDirtyRect(float historicalX, float historicalY) {
    if (historicalX < dirtyRect.left) {
      dirtyRect.left = historicalX;
    } else if (historicalX > dirtyRect.right) {
      dirtyRect.right = historicalX;
    }
    if (historicalY < dirtyRect.top) {
      dirtyRect.top = historicalY;
    } else if (historicalY > dirtyRect.bottom) {
      dirtyRect.bottom = historicalY;
    }
  }

  /**
   * Resets the dirty region when the motion event occurs.
   */
  private void resetDirtyRect(float eventX, float eventY) {

    // The lastTouchX and lastTouchY were set when the ACTION_DOWN
    // motion event occurred.
    dirtyRect.left = Math.min(lastTouchX, eventX);
    dirtyRect.right = Math.max(lastTouchX, eventX);
    dirtyRect.top = Math.min(lastTouchY, eventY);
    dirtyRect.bottom = Math.max(lastTouchY, eventY);
  }
}

If you want to try the real thing, download Square from the Android Market. If you read this far, Square is hiring.

Eric Burke
Android client programmer and aspiring woodworker

Comments

Get support help at squareup.com/support. We'll delete off-topic comments.