轉:https://corner.squareup.com/2010/07/smooth-signatures.html
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:
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:
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:
- Android sampled the touch screen at too low of a rate, or
- 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:
- Create a rectangle representing the dirty region.
-
Set the points for the four corners to the X and Y coordinates from the
ACTION_DOWN
event. -
For
ACTION_MOVE
andACTION_UP
, expand the rectangle to encompass the new points. (Don’t forget the historical coordinates!) -
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:
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.