Logo Icon Logo
A Crowd-sourced Cookbook on Writing Great Android® Apps
GitHub logo Twitter logo OReilly Book Cover Art

Freehand Drawing Smooth Curves

Author: Ian Darwin -- Published? true -- FormatLanguage: W

Problem:

You want to allow the user to draw smooth curves, such as freehand bezier curves, legal signatures, etc.

Solution:

Create a custom View with a carefully-written OnTouchListener that handles the case where input arrives faster than your code can process it; save the results in an array, and draw them in onDraw().

Discussion:

This code was originally written by Eric Burke of squareup.com for capturing signatures when people use squareup to capture credit card purchases. To be legally acceptable as proof of purchase intent, the signatures they capture have to be of good quality. Squareup has graciously placed this code under the Apache Software License 2.0, but were not able to provide their text as a Recipe for this cookbook.

I have since adapted the signature code for use in JabaGator, my very simple drawing program, which I hope to get into the Android Market in 2012. JabaGator is to be a general purpose drawing program for Java desktop and for Android, but the fact that the name rhymes with a well-known illustration program from Adobe is of course purely coincidental.

Eric's initial, "by the book" drawing code worked but was very jerky and very slow. Upon investigation, they learned that Android's graphics layer sends touch events in "batches" when it cannot deliver them quickly enough individually. Each MotionEvent delivered to onTouchEvent() may contain a number of touch coordinates, as many as were captured since the last onTouchEvent() call. To draw a smooth curve, you must get all of the points. You do this using the number of coordinates from the TouchEvent method getHistorySize(), iterating over that count, and calling getHistoricalX(int) and getHistoricalY(int) to get the point locations.

// in onTouchEvent(TouchEvent):
for (int i=0; i < event.getHistorySize(); i++) {
	float historicalX = event.getHistoricalX(i);
	float historicalY = event.getHistoricalY(i);
	... add point (historicalX, historicalY) to your path ...
}
... add point (eventX, eventY) to your path ...

This provides significant improvements, but still is too slow for people to draw with - many non-computer-geeks will wait for the drawing code to catch up with their finger if it doesn't draw quickly enough! The problem was that a simple solution calls invalidate() after each line segment, which is correct but very slow as it forces Android to redraw the entire screen. The solution to this problem is to use invalidate() with just the region that you drew the line segment into, and involves a bit of arithmetic to get the region correct; see the expandDirtyRect method below. The dirty-region algorithm is, in Eric's own words:

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

This makes the drawing code responsive, and the application usable.

Here is my version of the final code. I have several OnTouchListeners, one for drawing curves, one for selecting objects, one for drawing rectangles, etc. That code is not complete at present, but the curve drawing part works nicely.

// This code is dual-licensed under Creative Commons and Apache Software License 2.0
public class DrawingView 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();
    
      final OnTouchListener selectionAndMoveListener = // not shown;

      final OnTouchListener drawRectangleListener = // not shown;

      final OnTouchListener drawOvalListener = // not shown;
    
      final OnTouchListener drawPolyLineListener = new OnTouchListener() {
        
        @Override
        public boolean onTouch(View v, MotionEvent event) {
            // Log.d("jabagator", "onTouch: " + event);
            float eventX = event.getX();
            float eventY = event.getY();

            switch (event.getAction()) {
              case MotionEvent.ACTION_DOWN:
                path.moveTo(eventX, eventY);
                lastTouchX = eventX;
                lastTouchY = eventY;
                // 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 can be delivered to the app, 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:
                Log.d("jabagator", "Unknown 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);
          }
    };

    /** DrawingView Constructor */
    public DrawingView(Context context, AttributeSet attrs) {
        super(context, attrs);

        paint.setAntiAlias(true);
        paint.setColor(Color.WHITE);
        paint.setStyle(Paint.Style.STROKE);
        paint.setStrokeJoin(Paint.Join.ROUND);
        paint.setStrokeWidth(STROKE_WIDTH);
        
        setMode(MotionMode.DRAW_POLY);
    }
    
    public void clear() {
        path.reset();

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

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

    /**
     * Sets the DrawingView into one of several modes, such
     * as "select" mode (e.g., for moving or resizeing objects), 
     * or "Draw polyline" (smooth curve), "draw rectangle", etc.
     */
    private void setMode(MotionMode motionMode) {
        switch(motionMode) {
        case SELECT_AND_MOVE:
            setOnTouchListener(selectionAndMoveListener);
            break;
        case DRAW_POLY:
            setOnTouchListener(drawPolyLineListener);
            break;
        case DRAW_RECTANGLE:
            setOnTouchListener(drawRectangleListener);
            break;
        case DRAW_OVAL:
            setOnTouchListener(drawOvalListener);
            break;
        default:
            throw new IllegalStateException("Unknown MotionMode " + motionMode);
        }
    }
}

Here is JabaGator running, showing my attempt at legible handwriting.

This gives good drawing performance and smooth curves. The code to capture the curves into the drawing data model is not shown as it is application-specific.

See Also:

The original code and Eric's description can be found online at http://corner.squareup.com/2010/07/smooth-signatures.html.

Download:

The source code for this project can be downloaded from http://projects.darwinsys.com/jabagator.android-src.zip.
No records found.