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.
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:
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.
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.
}
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"/>
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.
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.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With