Using CoreText and touches to create a clickable action
Asked Answered
N

3

9

I have some code in a view which draws some attributed text using CoreText. In this, I'm searching for urls and making them blue. The idea is to not bring in all the overhead of a UIWebView just to get clickable links. Once a user taps on that link (not the whole table view cell), I want to fire off a delegate method which will then be used to present a modal view which contains a web view going to that url.

I'm saving the path and the string itself as instance variables of the view, and the drawing code happens in -drawRect: (I've left it out for brevity).

My touch handler however, while incomplete, is not printing what I'd expect it to. It is below:

- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event
{
    UITouch *touch = [touches anyObject];
    CGPoint point = [touch locationInView:self];
    CGContextRef context = UIGraphicsGetCurrentContext();

    NSLog(@"attribString = %@", self.attribString);
    CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)self.attribString);
    CTFrameRef ctframe = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, self.attribString.length), attribPath, NULL);

    CFArrayRef lines = CTFrameGetLines(ctframe);
    for(CFIndex i = 0; i < CFArrayGetCount(lines); i++)
    {
        CTLineRef line = CFArrayGetValueAtIndex(lines, i);
        CGRect lineBounds = CTLineGetImageBounds(line, context);

        // Check if tap was on our line
        if(CGRectContainsPoint(lineBounds, point))
        {
            NSLog(@"Tapped line");
            CFArrayRef runs = CTLineGetGlyphRuns(line);
            for(CFIndex j = 0; j < CFArrayGetCount(runs); j++)
            {
                CTRunRef run = CFArrayGetValueAtIndex(runs, j);
                CFRange urlStringRange = CTRunGetStringRange(run); 
                CGRect runBounds = CTRunGetImageBounds(run, context, urlStringRange);

                if(CGRectContainsPoint(runBounds, point))
                {
                    NSLog(@"Tapped run");
                    CFIndex* buffer = malloc(sizeof(CFIndex) * urlStringRange.length);
                    CTRunGetStringIndices(run, urlStringRange, buffer);
                    // TODO: Map the glyph indices to indexes in the string & Fire the delegate
                }
            }
        }
    }
}

It's not the prettiest code at the moment, I'm still trying to just make it work, so forgive the code quality.

The problem I'm having is that when I tap outside the link, what I expect would happen, happens: Nothing gets fired.

However, I would expect "Tapped line" to get printed if I tap the same line the link is on, which doesn't happen, and I would expect both "Tapped line" and "Tapped run" to get printed if I tap on the URL.

I'm unsure as to where to take this further, the resources I've looked at for resolution to this issue are Cocoa specific (which is almost completely inapplicable), or lacking information on this specific case.

I'll gladly take pointers to documentation which detail how to properly go about detecting if a touch occurred within the bounds of a core text drawing over code, but at this point, I just want to resolve this problem, so any help would be greatly appreciated.

UPDATE: I have narrowed down my problem to a coordinate issue. I have flipped the coordinates (and not as shown above) and the problem I'm getting is that touches register as I'd expect, but the coordinate space is flipped, and I can't seem to flip it back.

Novation answered 27/9, 2010 at 9:53 Comment(3)
Have you taken into account the fact that CoreText uses a flipped coordinate system? Right now it looks to me like you're comparing things in two different coordinate systems.Warmonger
Also, it's a bad idea to recreate the framesetter on every touch like that. Creating a framesetter is very expensive, so you should cache it when first drawing or setting the text.Warmonger
My development methodology is simply: 1) Make it work; 2) Make it right; 3) Make it fast/clean. Let's deal with #2 when we get #1 going :) Also regarding the coordinate system, yes I do realize that, and for a while I wasn't handling it. In the code now, after consulting with a colleague, he set me straight on this, and it's at least detecting taps on the lines now, just not the runs. Still trying to figure that one out.Novation
F
9

I have just done this to get the string character index from the touch position. The line number would be i in this case:

- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event {
    NSLog(@"touch ended");

    UITouch* touch = [touches anyObject];

    CGPoint pnt = [touch locationInView:self];

    CGPoint reversePoint = CGPointMake(pnt.x, self.frame.size.height-pnt.y);

    CFArrayRef lines = CTFrameGetLines(ctFrame);

    CGPoint* lineOrigins = malloc(sizeof(CGPoint)*CFArrayGetCount(lines));

    CTFrameGetLineOrigins(ctFrame, CFRangeMake(0,0), lineOrigins);

    for(CFIndex i = 0; i < CFArrayGetCount(lines); i++)
    {
        CTLineRef line = CFArrayGetValueAtIndex(lines, i);

        CGPoint origin = lineOrigins[i];
        if (reversePoint.y > origin.y) {
            NSInteger index = CTLineGetStringIndexForPosition(line, reversePoint);
            NSLog(@"index %d", index);
            break;
        }
    }
    free(lineOrigins);
}
Fritzsche answered 12/4, 2012 at 18:32 Comment(5)
This works great, I can compare this to a set of ranges I have stored for tappable words. This is much faster than my old approach - thank you :)Milord
How can I get ctFrame?Cowherb
It's the CTFrameRef object of the core text frame currently in view. In my implementation it is a member variable, created with CTFramesetterCreateFrameFritzsche
Thank you very much. It works but I did't understand that why we must create CGPoint reversePoint and use this point for detech touch. What is the formula to calculate the reversePoint. Can you explain it for me in more details.Loar
Hi, yes - in core text (as in OSX dev) height position is reversed so bottom left corner is 0,0 - whereas in UIKit top left is 0,0!Fritzsche
C
0

You could try to add your text drawing into a CALayer, add this new layer as sublayer of your views layer and hittest against it in your touchesEnded? If you do so, you should be able to create a bigger touchable area by making the layer bigger than the drawn text.

// hittesting 
UITouch *touch = [[event allTouches] anyObject];
touchedLayer = (CALayer *)[self.layer hitTest:[touch locationInView:self]];
Constitutionally answered 27/9, 2010 at 12:7 Comment(4)
Then I'd have another problem of having to split my text into two different layers -- the one that I want to trigger the delegate, and then the rest. The problem I'd run into there, is what happens if the URL is in the middle of a line and there's text before and after it? Then I'd have to split up into many different layers. It could get hairy if there's a few URLs in the group of text I render. I'd prefer to just fetch a list of all the items in the attrib string i've coloured blue, and get their bounding boxes, see if the user tapped inside there and call the delegate with that url.Novation
True, this could be a problem. Maybe I didn't understand your issue right. How about keeping the text as it is and overlay a hittable layer to catch these touches?Constitutionally
I still need to know exactly where to place it -- which means i still need to get the rect of the URL. That puts me back to square one. :)Novation
My problem is that I have text, I know where in that text the link is, but after I draw it, I need to find that link. I started off just trying to find ANY run of glyphs, but nothing is firing to the point where my NSLog()'s are showing that I've hit a line, or hit a run of glphs. Without being able to recognize that I've hit some text, I have no hope of finding out if the text I've hit is the URL.Novation
O
0

Swift 3 version of Nick H247's answer:

var ctFrame: CTFrame?

override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
    guard let touch = touches.first, let ctFrame = self.ctFrame else { return }

    let pnt = touch.location(in: self.view)
    let reversePoint = CGPoint(x: pnt.x, y: self.frame.height - pnt.y)
    let lines = CTFrameGetLines(ctFrame) as [AnyObject] as! [CTLine]

    var lineOrigins = [CGPoint](repeating: .zero, count: lines.count)
    CTFrameGetLineOrigins(ctFrame, CFRange(location: 0, length: 0), &lineOrigins)

    for (i, line) in lines.enumerated() {
        let origin = lineOrigins[i]

        if reversePoint.y > origin.y {
            let index = CTLineGetStringIndexForPosition(line, reversePoint)
            print("index \(index)")
            break
        }
    }
}
Over answered 6/1, 2017 at 2:36 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.