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

3

9

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.

Yarborough answered 31/8, 2015 at 9:46 Comment(5)
I would start by taking the touchLocation point, and finding out the distance from the line between your start and end point. Once the distance becomes small, the touch point will be on the line.Avion
@TimEdwards Sounds good logically, perhaps some code would help, also how would that work on a curved line? Thanks a lot for helping out.Yarborough
I don't know doinkers about iOS, but I did a quick search and found CGPath. It has the ability to do all sorts of stuff, including rects, lines and curves, and also has a seemingly appropriate CGPathContainsPoint. Would that class not work for you?Ebner
@Ebner Thank you so much for looking into my question buddy. Your suggestion is good and I have already tried it. To be honest, I have some performance issues when I use CGPath. I need to redraw the line every time the user moves the end point or the start point, and CGPath does not perform really well in this situation, or probably I am not doing it the right way. Thanks a lot again! Cheers.Yarborough
I'm not sure CGPath and CGPathContainsPoint would be useful in this situation. From the documentation: "A point is contained in a path if it would be inside the painted region when the path is filled." So basically it returns true for points "surrounded" by the path. If you just have a line segment, even if it's curved, it's probably not surrounding anything.Illusionism
B
4

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"/>
Bainter answered 5/10, 2015 at 13:52 Comment(7)
Thanks @Kaganar. I'm struggling a bit to convert this into Objective-C code easily, and it may take a while. Would you please help to convert it if you have it handy? Also, how would this logic fare with a curved line?Yarborough
I must apologize, I'm not familiar with Objective-C -- although the applicable syntax in this instance seems almost the same. I suspect what's even more of an issue is how to integrate this into Apple's iOS environment, and I'm afraid I'm not familiar with how precisely this environment is structured. Regarding curved lines, if you can represent the curved line as a series of segments then you'd need to run this test on each segment and use the point that's the closest to the touched point. I'm not sure what your curved line interface or representation looks like, so I can't be more specific.Bainter
No problem then. I'll spend some time converting it and check if it suits the need. The bounty however ends soon. Will I still be able to award the bounty once the period ends? Sorry, but this is my first bounty question :)Yarborough
@Gurtej, No worries -- I believe there's a grace period in which you still have time to issue the bounty? Looks like a 24 hour grace period --> stackoverflow.com/help/bounty (near the bottom)Bainter
Unfortunately this does not solve my problem still. Either I am not converting this correctly to Obj-C or this is probably not going to work since the start point and end points are dynamic at all times. However, I seem to have found a thread which could be of some help:#12923289. Since you pointed me to the right direction, I will grant you the bounty in case I do not receive a better answer before the bounty ends. Thanks a lot for helping out.Yarborough
@Gurtej, Wish I could help you with the more iOS-centric stuff, but I did update the question with a live Javascript demo in case it helps anyone.Bainter
Thanks for showing the JS canvas example. It works great. But I have not been able to solve the issue yet in Obj-C, but I have made some good progress with your suggested direction, and hence I am awarding you the bounty. I will continue to research and develop more on your idea, and post an answer once I have it working completely. Thank you so much for your interest and help. Appreciate it :)Yarborough
L
1

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.

Lindeman answered 1/10, 2015 at 16:22 Comment(2)
That sounds interesting, however what about performance? Would you draw a path every time a touch happens? Keep in mind that could be many such instances of these lines and performance is a key factor here. It would be great if you could provide some code to support your answer and also explain how it would work in cases of a curved line. Appreciate your help.Yarborough
Thanks for the updated answer @Aris. Yes, I am drawing only one instance each time a change happens. So there would be multiple instances of this class on the stage and user can drag any one of them. The problem is the selection hit area. For the curved line, of course there would be a third point as well for the bezier. Even if I store the information in a model as you suggested, I still would have to draw a CGPath every time a touch happens on all the instances. This would be very performance intensive. Thoughts?Yarborough
N
1

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.

Neoterism answered 6/10, 2015 at 17:5 Comment(2)
While I understand the logic and really appreciate your help, I'm looking for more code related examples to help me out here. The curved path will have max 3 points, a start point, and end point and a control point to control the amount of curve. Thank you once again.Yarborough
Thanks for updating your answer, however your recommendation is pretty much the same of what @kaganar has suggested above, and it does not show Obj-C code, but it has put me in the right direction. I appreciate your support. Thanks.Yarborough

© 2022 - 2024 — McMap. All rights reserved.