How To: Identify which pointer moved in Android

Heads up: When I say pointer in this article, I mean a finger or a stylus – anything in contact with the touchscreen.

Multi-touch in Android is a bit of a strange beast, although you could argue that it’s by necessity. With ACTION_DOWN (primary pointer in contact) or ACTION_POINTER_DOWN (non-primary pointer in contact), or even the corresponding ACTION_UP or ACTION_POINTER_UP events it’s fine. But when it comes to ACTION_MOVE events – you can’t just ask which finger moved, all you’re told is that A finger moved, and you have to figure out which one for yourself as far as I can tell.

This is what I’ve come up with to figure out which pointer has actually moved – it falls behind by one ACTION_MOVE event because there doesn’t seem to be a way to ask for the current X location of a pointer by its index location (that is, there’s no getX(pointerIndex), only a getHistoricalX(pointerIndex, historyPosition)) – so this code misses the very first move event when a pointer moves – BUT – moving your finger generates a large number of move events, so I’m thinking this might not be that much of an issue.

Update: I’d forgotten you can getX(pointerIndex) and as well as getHistoricalX(pointerIndex, historyLocation) – so I’ve modified the code below to do that, which means I don’t think we miss any move events anymore because we’re now comparing current location to historical(0) rather than historical(0) to historical(1).

Anyway, here’s my code which addresses this finicky problem:

@Override
public boolean onTouchEvent(MotionEvent event)
{
    // Get pointer index from the event object...
    int pointerIndex = event.getActionIndex();
 
    // ...which we can use to find the pointer ID!
    int pointerId = event.getPointerId(pointerIndex);
 
    // Get masked action (i.e. action which is not specific to a pointer)
    int maskedAction = event.getActionMasked();
 
    // Depending on what the action was, act appropriately...
    switch (maskedAction)
    {
        case MotionEvent.ACTION_DOWN:
            System.out.println("Pointer with Id: " + pointerId + " at index " + pointerIndex + " down i.e. Primary pointer down!");
            break;
 
        case MotionEvent.ACTION_POINTER_DOWN:
            System.out.println("Pointer with Id: " + pointerId + " at index " + pointerIndex + " down i.e. NON-Primary pointer down!");
            break;
 
        // The MOVE event must be done separately. This is because the ACTION_MOVE event always gets
        // zero as the pointerId for some strange reason. As such, you always have to loop over the
        // pointer indexes and compare positions to the pointer's previous (i.e. historical) position.
        case MotionEvent.ACTION_MOVE:
            int pointerCount = event.getPointerCount();
            for(int i = 0; i < pointerCount; ++i)
            {
                // To find out WHICH pointer moved we must compare pointer historical locations
                if (event.getHistorySize() > 0)
                {
                    // X or Y location for that pointer index moved?
                    // Corner-case: Pointer index changed (pointer up or down promoted or demoted pointer index while moving?) 
                    // Fix: Track by pointer Id via sparse array as outlined in second potential solution below.
                    if ( (int)event.getX(i) != (int)event.getHistoricalX(i,0) || (int)event.getY(i) != (int)event.getHistoricalY(i, 0) )
                    {
                        pointerId = event.getPointerId(i);
                        System.out.println("Pointer with Id: " + pointerId + " at index " + i + " moved!");
                    }
                }
            }
            break;
 
        case MotionEvent.ACTION_UP:
            System.out.println("Pointer with Id: " + pointerId + " at index " + pointerIndex + " up i.e. Primary pointer up!");
            break;
 
        case MotionEvent.ACTION_POINTER_UP:
            System.out.println("Pointer with Id: " + pointerId + " at index " + pointerIndex + " up i.e. NON-Primary pointer up!");
            break;
    }
 
    // Consume the event so that it is not processed any further
    return true;
 
} // End of onTouchEvent method

As I’ve been playing around with this it seems to match up with what I’m doing very well without issue, for example:

Pointer with Id: 0 at index 0 down i.e. Primary pointer down!      <--- 1st finger down
Pointer with Id: 0 at index 0 moved!                               <--- 1st finger moved
Pointer with Id: 0 at index 0 moved!
...
Pointer with Id: 1 at index 1 down i.e. NON-Primary pointer down!  <--- 2nd finger down
Pointer with Id: 1 at index 1 moved!                               <--- 2nd finger moved
Pointer with Id: 1 at index 1 moved!
...
Pointer with Id: 0 at index 0 up i.e. NON-Primary pointer up!      <--- 1st finger up, promoting 2nd finger to primary!
Pointer with Id: 1 at index 0 moved!                               <--- 2nd finger (now primary) moved
Pointer with Id: 1 at index 0 moved!
...
Pointer with Id: 0 at index 0 down i.e. NON-Primary pointer down!  <--- 1st finger back down, DEMOTING 2nd finger from primary!
Pointer with Id: 0 at index 0 moved!                               <--- 1st finger moved
...
Pointer with Id: 1 at index 1 up i.e. NON-Primary pointer up!      <--- 2nd finger up
Pointer with Id: 0 at index 0 up i.e. Primary pointer up!          <--- 1st finger up

Alternative Solution

Keep a sparse array of whatever you're keeping track of - for example, let's say we've got the world's simplest Circle class:

public class Circle
{
	public float x;
	public float y;
}

Then in your class handling the onTouchEvent() method you could have something like this (in this particular example we have a view which responds to multi-touch events):

// imports here...
 
public class MultiTouchCircleView extends View
{
    private static final int SIZE = 150;
 
    // Note: A SparseArray is kind-of like a hash-map, it maps an
    // integer to an object in a key/value manner.
    private SparseArray<Circle> circleArray;
 
    private Paint paint;
 
    // Array of 10 colours
    private int[] colours = {Color.BLUE, Color.GREEN, Color.RED,
            Color.YELLOW, Color.CYAN, Color.GRAY, Color.MAGENTA, Color.DKGRAY,
            Color.LTGRAY, Color.YELLOW};
 
    private Paint textPaint;
 
    public MultiTouchCircleView(Context context, AttributeSet attrs)
    {
        super(context, attrs);
        initView();
    }
 
    private void initView()
    {
        circleArray = new SparseArray<Circle>();
        paint = new Paint(Paint.ANTI_ALIAS_FLAG);
 
        // set painter color to a color you like
        paint.setColor(Color.BLUE);
        paint.setStyle(Paint.Style.FILL_AND_STROKE);
        textPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        textPaint.setTextSize(50);
        textPaint.setColor(Color.BLUE);
    }
 
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        // Get pointer index from the event object
        int pointerIndex = event.getActionIndex();
 
        // Get pointer ID
        int pointerId = event.getPointerId(pointerIndex);
 
        // Get masked (not specific to a pointer) action
        int maskedAction = event.getActionMasked();
 
        switch (maskedAction) {
 
            case MotionEvent.ACTION_DOWN:
            case MotionEvent.ACTION_POINTER_DOWN: {
                // A finger has touched the screen - so let's create a new
                // Circle at the touch location and add it to the list!
                Circle circle = new Circle();
                circle.x = event.getX(pointerIndex);
                circle.y = event.getY(pointerIndex);
                circleArray.put(pointerId, circle);
                break;
            }
 
            case MotionEvent.ACTION_MOVE: {
                // How many pointers are in contact with the screen?
                int pointerCount = event.getPointerCount();
 
                // Loop over them all...
                for (int i = 0; i < pointerCount; ++i) {
                    // i is the pointer index but we'll update our pointerIndex variable for clarity
                    pointerIndex = i;
 
                    // Get the pointerId at that pointerIndex (pointerId never changes while pointer is in contact)
                    pointerId = event.getPointerId(pointerIndex);
 
                    // Get access to the circle with that pointer id...
                    Circle circle = circleArray.get(pointerId);
 
                    // ...and update the circle's location.
                    circle.x = event.getX(pointerIndex);
                    circle.y = event.getY(pointerIndex);
                }
                break;
            }
 
            case MotionEvent.ACTION_UP:
            case MotionEvent.ACTION_POINTER_UP: {
                // Finger has left contact - remove the circle from the array
                circleArray.remove(pointerId);
                break;
            }
 
        } // End of switch block
 
        // Trigger redraw to display any changes
        invalidate();
 
        // Return true to consume the MotionEvent
        return true;
    }
 
    @Override
    protected void onDraw(Canvas canvas)
    {
        super.onDraw(canvas);
 
        // Draw circles for each pointer in contact with the screen
        int numCircles = circleArray.size();
        for (int i = 0; i < numCircles; ++i)
        {
            // Get the circle at index location i
            Circle circle = circleArray.valueAt(i);
 
            // ...to choose a colour between 0 and 9.
            if (circle != null)
            {
                int colourNum = i % 9;
                paint.setColor(colours[colourNum]);
                paint.setColor(colours[i]);
            }
 
            // Draw a circle in our chosen colour
            canvas.drawCircle(circle.x, circle.y, SIZE, paint);
        }
        canvas.drawText("Total pointers: " + numCircles, 10, 100, textPaint);
 
    } // End of onDraw method
 
} // End of MultiTouchCircleView class

This technique doesn't miss any ACTION_MOVE events because it looks them up by pointerId in the SparseArray - which is potentially a nicer solution. Go with whatever works for you.