Search code examples
androidandroid-canvasandroid-custom-view

How to get touched shape (arc) from Android canvas?


I'm building a custom view.

It has 4 arcs.

I'm drawing arcs using a RectF shape, during onDraw():

// arcPaint is a Paint object initialized
// in the View's constructor
arcPaint.setStrokeWidth(strokeWidth);
arcPaint.setColor(arcColor);

// Draw arcs, arc1, arc2, arc3 & arc4 are
// measured and initialized during onMeasure()
canvas.drawArc(arc1, startAngle, arc1Angle, false, arcPaint);
canvas.drawArc(arc2, startAngle, arc2Angle, false, arcPaint);
canvas.drawArc(arc3, startAngle, arc3Angle, false, arcPaint);
canvas.drawArc(arc4, startAngle, arc4Angle, false, arcPaint);

So, the result is:

enter image description here

If I draw the RectF objects using canvas.drawRect() with this code:

canvas.drawRect(arc1, arcPaint);
canvas.drawRect(arc2, arcPaint);
canvas.drawRect(arc3, arcPaint);
canvas.drawRect(arc4, arcPaint);

the result is this:

enter image description here

Drawing all together, arcs and rects, gives this:

enter image description here

I know I can override the onTouchEvent() method and get the X, Y coordinates, but I don't know how every coordinate relates to each shape drawn in the canvas.

What I want to do, is to detect when an arc, not a rect, is touched within the canvas, how can I achieve that?

Edit:

By, not a rect, I mean, I don't want to detect when the user touches these areas (the corners of the rect):

enter image description here


Solution

  • This basically boils down to comparing distances from the center point of that figure. Since the bounding rectangles will always be square, one of those distances is essentially fixed, which makes this much easier.

    The general methodology here is to calculate the distance from the center point to the touch point, and check if the difference of that distance and the arc's radius is within half of the stroke width either way.

    private static boolean isOnRing(MotionEvent event, RectF bounds, float strokeWidth) {
        // Figure the distance from center point to touch point.
        final float distance = distance(event.getX(), event.getY(),
                                        bounds.centerX(), bounds.centerY());
    
        // Assuming square bounds to figure the radius.
        final float radius = bounds.width() / 2f;
    
        // The Paint stroke is centered on the circumference,
        // so the tolerance is half its width.
        final float halfStrokeWidth = strokeWidth / 2f;
    
        // Compare the difference to the tolerance.
        return Math.abs(distance - radius) <= halfStrokeWidth;
    }
    
    private static float distance(float x1, float y1, float x2, float y2) {
        return (float) Math.sqrt(Math.pow(x1 - x2, 2) + Math.pow(y1 - y2, 2));
    }
    

    In the onTouchEvent() method, isOnRing() is simply called with the MotionEvent, the arc's bounding RectF, and the Paint's stroke width. For example:

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                if (isOnRing(event, arc1, arcPaint.getStrokeWidth())) {
                    // Hit.
                }
                break;
                ...
        }
        ...
    }
    

    This by itself will only determine if the touch point is anywhere on the complete ring described by the radius and stroke width. That is, it doesn't account for "gaps" in the arc. There are multiple ways to handle that, if needed.

    If you intend to have the start and sweep angles flush with the x/y axes, the simplest method would probably be to check the signs on the differences in the coordinates of the touch point and center point. In broad terms, if the touch x minus the center x is positive, then you're on the right side; if negative, you're on the left. Similarly figuring for top or bottom will allow you to determine in which quadrant the touch occurred, and whether to ignore the event.

    final float dx = event.getX() - arc1.centerX();
    final float dy = event.getY() - arc1.centerY();
    
    if (dx > 0) {
        // Right side
    }
    else {
        // Left side
    }
    ...
    

    However, if either angle is not to be coterminal with an axis, then the math is a little more involved. For example:

    private static boolean isInSweep(MotionEvent event, RectF bounds,
                                     float startAngle, float sweepAngle) {
        // Figure atan2 angle.
        final float at =
            (float) Math.toDegrees(Math.atan2(event.getY() - bounds.centerY(),
                                              event.getX() - bounds.centerX()));
    
        // Convert from atan2 to standard angle.
        final float angle = (at + 360) % 360;
    
        // Check if in sweep.
        return angle >= startAngle && angle <= startAngle + sweepAngle;
    }
    

    The if in onTouchEvent() would then change to incorporate both checks.

    if (isOnRing(event, arc1, arcPaint.getStrokeWidth()) &&
        isInSweep(event, arc1, startAngle, arc1Angle)) {
        // Hit.
    }
    

    These methods could easily be combined into one, but they're left separate here for clarity's sake.