Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to detect touches on a draggable line (drawn using drawRect)

Calling all experts! I have seen various posts, and to be honest, my need is a little different than the answers available on SO.

I want to create a UI where the user can create various lines (straight, curved, wiggled etc) onto a specific area (lets call this "canvas" for now). There can be multiple instances of each of the lines. The user then has the ability to drag and edit these lines based on their need. So, they can stretch it, change the start point, end point etc, or even drag the entire line to within the bounds of the canvas.

I have managed to draw the line (using drawRect) and show draggable handles at the ends of each line (see reference image), and the user can drag the end points within the bounds (red rectangle) of the canvas to suit the need.

enter image description here

The problem I am facing is how to tap to activate edit for a particular line. So, by default, the drag handles will not be visible, and the user can tap on the line to activate 'edit' mode lets say, and show the handles (tap again to deselect). So, in the diagram above, I want to be able to detect touches in the yellow rectangle. Keep in mind that the UIView bounds is the entire canvas area, to allow users to drag freely, so detecting touches is clearly difficult since there are transparent areas as well, and there can be multiple instance of each line.

Here's my code so far for the line class (startHandle and endHandle are the handles at each end):

-(void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
{
    CGPoint startPoint = CGPointMake(self.startHandle.frame.origin.x + self.startHandle.frame.size.width/2, self.startHandle.frame.origin.y + self.startHandle.frame.size.height/2);

    CGPoint endPoint = CGPointMake(self.endHandle.frame.origin.x + self.endHandle.frame.size.width/2, self.endHandle.frame.origin.y + self.endHandle.frame.size.height/2);

    UITouch *touch = [[event allTouches] anyObject];
    CGPoint touchLocation = [touch locationInView:self];

    if (CGRectContainsPoint(CGRectMake(startPoint.x, startPoint.y, endPoint.x - startPoint.x , endPoint.y - startPoint.y), touchLocation))
    {
        //this is the green rectangle! I want the yellow one :)
        NSLog(@"TOUCHED IN HIT AREA");
    }
}

- (void)drawRect:(CGRect)rect {
    [super drawRect:rect];

    CGContextRef context = UIGraphicsGetCurrentContext();
    CGContextClearRect(context, self.bounds);

    CGPoint startPoint = CGPointMake(self.startHandle.frame.origin.x + self.startHandle.frame.size.width/2, self.startHandle.frame.origin.y + self.startHandle.frame.size.height/2);

    CGPoint endPoint = CGPointMake(self.endHandle.frame.origin.x + self.endHandle.frame.size.width/2, self.endHandle.frame.origin.y + self.endHandle.frame.size.height/2);


    CGContextSetStrokeColorWithColor(context, [UIColor blackColor].CGColor);
    CGContextSetLineWidth(context, 2.0f);
    CGContextMoveToPoint(context, startPoint.x, startPoint.y ); //start at this point
    CGContextAddLineToPoint(context, endPoint.x, endPoint.y); //draw to this point

    self.arrow.angle = [self pointPairToBearingDegrees:startPoint secondPoint:endPoint];
    self.arrow.center = endPoint;
    [self.arrow setNeedsDisplay];
    // and now draw the Path!
    CGContextStrokePath(context);

}

Things I have tried so far:

  1. Try to detect touches within a rectangle drawn between the start point and the end point, but this does return the rectangle I need, since it's of larger area if the angle of the line is lets say 45 degrees (see green rectangle)
  2. Tried to draw another thicker line on top in drawRect, but in vain, since I would have to make it transparent, and its the same as any other area of the view. Also tried to detect color, but again this thicker line has to be transparent.

I would really appreciate any help in this direction. Bonus points if you can show me how to do this for a curved line. Thank you so much for reading through.

like image 261
Gurtej Singh Avatar asked Aug 31 '15 09:08

Gurtej Singh


3 Answers

You could just do a bit of math. When a touch event occurs you're looking to see what the nearest point on the line is to the touch event, and how far that nearest point is from the touch event. If it's too far, you'll want to treat the touch event as if it was not pertaining to the line. If it's close enough, you'll want to treat it as if it was at the nearest point on the line.

Fortunately, the hard work has been done already, but you'll want to refactor the code a little to return the nearest point instead, for example:

// Return point on line segment vw with minimum distance to point p
vec2 nearest_point(vec2 v, vec2 w, vec2 p) {

  const float l2 = (v.x-w.x)*(v.x-w.x) + (v.y-w.y)*(v.y-w.y);
  if (l2 == 0.0) return v;   // v == w case

  // Consider the line extending the segment, parameterized as v + t (w - v).
  // We find projection of point p onto the line. 
  // It falls where t = [(p-v) . (w-v)] / |w-v|^2

  const float t = ((p.x-v.x)*(w.x-v.x) + (p.y-v.y)*(w.y-v.y)) / l2;

  if (t < 0.0) return v;       // Beyond the 'v' end of the segment
  else if (t > 1.0) return w;  // Beyond the 'w' end of the segment

  vec2 projection;
  projection.x = v.x + t * (w.x - v.x);
  projection.y = v.y + t * (w.y - v.y);

  return projection;
}

... and later in code used in the touch event handler ...

vec2 np = nearest_point(linePoint0, linePoint2, touchPoint);

// Compute the distance squared between the nearest point on
// the line segment and the touch point.
float distanceSquared = (np.x-touchPoint.x)*(np.x-touchPoint.x) + (np.y-touchPoint.y)*(np.y-touchPoint.y);

// How far the touch point can be from the line segment
float maxDistance = 10.0;

// This allows us to avoid using square root.
float maxDistanceSquared = maxDistance * maxDistance;

if (distanceSquared <= maxDistanceSquared) {
  // The touch was on the line.
  // We should treat np as the touch point.
} else {
  // The touch point was not on the line.
  // We should treat touchPoint as the touch point.
}

Update

Here's a working proof-of-concept for most of this, either at the jsfiddle here or embedded below (best run as full page):

function nearest_point(v, w, p) {
    var l2 = (v.x-w.x)*(v.x-w.x) + (v.y-w.y)*(v.y-w.y);
    if (l2 === 0.0) return v;
    var t = ((p.x-v.x)*(w.x-v.x) + (p.y-v.y)*(w.y-v.y)) / l2;
    if (t < 0.0) return v;
    else if (t > 1.0) return w;
    var projection = {};
    projection.x = v.x + t * (w.x - v.x);
    projection.y = v.y + t * (w.y - v.y);
    return projection;
}

var cvs = document.getElementsByTagName('canvas')[0];
var ctx = cvs.getContext('2d');
var width = cvs.width, height = cvs.height;

function LineSegment() {
    this.x0 = this.y0 = this.x1 = this.y1 = 0.0;
}

LineSegment.prototype.Set = function(x0, y0, x1, y1) {
    this.x0 = x0;
    this.y0 = y0;
    this.x1 = x1;
    this.y1 = y1;
}

var numSegs = 6;
var lineSegs = [];
for (var i = 0; i < numSegs; i++) lineSegs.push(new LineSegment());

ctx.lineCap = ctx.lineJoin = 'round';

var mouseX = width / 2.0, mouseY = width / 2.0;
var mouseRadius = 10.0;

var lastTime = new Date();
var animTime = 0.0;
var animate = true;
function doFrame() {
    // We record what time it is for animation purposes
    var time = new Date();
    var dt = (time - lastTime) / 1000; // deltaTime in seconds for animating
    lastTime = time;
    if (animate) animTime += dt;
    
    // Here we create a list of animated line segments
    for (var i = 0; i < numSegs; i++) {
        lineSegs[i].Set(
            width * i / numSegs,
            Math.sin(4.0 * i / numSegs + animTime) * height / 4.0 + height / 2.0,
            width * (i + 1.0) / numSegs,
            Math.sin(4.0 * (i + 1.0) / numSegs + animTime) * height / 4.0 + height / 2.0
        );
    }
    
    // Clear the background
    ctx.fillStyle = '#cdf';
    ctx.beginPath();
    ctx.rect(0, 0, width, height);
    ctx.fill();
    
    // Compute the closest point on the curve.
    var closestSeg = 0;
    var closestDistSquared = 1e100;
    var closestPoint = {};
    
    for (var i = 0; i < numSegs; i++) {
        var lineSeg = lineSegs[i];
        var np = nearest_point(
            {x: lineSeg.x0, y: lineSeg.y0},
            {x: lineSeg.x1, y: lineSeg.y1},
            {x: mouseX, y: mouseY}
        );
        
        ctx.fillStyle = (i & 1) === 0 ? 'rgba(0, 128, 255, 0.3)' : 'rgba(255, 0, 0, 0.3)';
        ctx.beginPath();
        ctx.arc(np.x, np.y, mouseRadius * 1.5, 0.0, 2.0 * Math.PI, false);
        ctx.fill();
        
        var distSquared = (np.x - mouseX) * (np.x - mouseX)
        	+ (np.y - mouseY) * (np.y - mouseY);
        if (distSquared < closestDistSquared) {
            closestSeg = i;
            closestDistSquared = distSquared;
            closestPoint = np;
        }
    }
    
    // Draw the line segments
    //ctx.strokeStyle = '#008';
    ctx.lineWidth = 10.0;
    for (var i = 0; i < numSegs; i++) {
        if (i === closestSeg) {
        	ctx.strokeStyle = (i & 1) === 0 ? '#08F' : '#F00';
        } else {
            ctx.strokeStyle = (i & 1) === 0 ? '#036' : '#600';
        }
    	ctx.beginPath();
        var lineSeg = lineSegs[i];
        ctx.moveTo(lineSeg.x0, lineSeg.y0);
        ctx.lineTo(lineSeg.x1, lineSeg.y1);
        ctx.stroke();
    }
    
    // Draw the closest point
    ctx.fillStyle = '#0f0';
    ctx.beginPath();
    ctx.arc(closestPoint.x, closestPoint.y, mouseRadius, 0.0, 2.0 * Math.PI, false);
    ctx.fill();
    
    // Draw the mouse point
    ctx.fillStyle = '#f00';
    ctx.beginPath();
    ctx.arc(mouseX, mouseY, mouseRadius, 0.0, 2.0 * Math.PI, false);
    ctx.fill();
    
	requestAnimationFrame(doFrame);
}

doFrame();

cvs.addEventListener('mousemove', function(evt) {
    var x = evt.pageX - cvs.offsetLeft,
        y = evt.pageY - cvs.offsetTop;
    mouseX = x;
    mouseY = y;
}, false);

cvs.addEventListener('click', function(evt) {
    animate = !animate;
}, false);
Move mouse over canvas to control the red dot.<br/>
Click on canvas to start/stop animation.<br/>
Green is the closest point on the curve.<br/>
Light red/blue is the closest point on each segment.<br/>
<canvas width="400" height="400"/>
like image 59
Kaganar Avatar answered Oct 05 '22 22:10

Kaganar


You could create a CGPath in touchesBegan with the start and end point of the line and then use CGPathContainsPoint() to figure out if the touch was on the line.

Edit: I am not sure how you could create on the fly the paths for curved lines with only the start and end point.

I think that a model would be needed for this. You would need to store the information of each line when it is created (as a CGPath or something else), also update it after each action. Then use that to find the line that was touched.

Have you given up on the idea of using CALayers for each line? You could have performance gains because you would only need to redraw only one line in each action instead of the whole thing.

P.S. I am no expert in this so there could be ways of dealing with this that are more clever.

like image 30
Aris Avatar answered Oct 06 '22 00:10

Aris


If you have an arbitrarily curved line between the start and the end point, you had to represent this curve by a point set, i.e. a number of points that represent the curve. This could be the pixels of the curve on the screen, or at less resolution, a number of points between which the curve can be interpolated appropriately well by straight lines.
To find out, if a user touched close enough to the curve, you could attach a tap gesture recognizer to the view into which the curve is drawn. When the view is tapped, it delivers you the location of the tap, see the docs.
Using this location, you had to compute the distance between this point and all points of the curve. If the minimum of these values is small enough, the user has touched the curve.

EDIT (due to the comment below):

If the curve consists only of the start point, the end point, and possibly a control point in between, then it is a single line segment or two line segments. In this case, the problem is to find the distance between a point and a line segment.
Then, maybe the answer (including code) given here is helpful.

like image 33
Reinhard Männer Avatar answered Oct 05 '22 23:10

Reinhard Männer