OHAttributedLabel icon indicating copy to clipboard operation
OHAttributedLabel copied to clipboard

UILineBreakMode*Truncation modes (leading/trailing "…") and multiline

Open AliSoftware opened this issue 14 years ago • 22 comments

Word Wrapping and Truncation is not well managed.

Today the OHAttributed does not support both multiline (numberOfLines>1) and "Truncation" lineBreakMode alltogether : if you choose one of the UILineBreakMode*Truncation mode, only the first line will be displayed.

It is probably easy to add the UILineBreakModeTailTruncation mode support by using the VisibleStringRange information (I just don't have time to add this right now)

AliSoftware avatar Feb 18 '11 19:02 AliSoftware

Hi!

This library is a great contribution, thanks so much for putting this together! Can you give a little more indication how to implement this?

Gon

gon avatar May 27 '11 23:05 gon

Hi @gon

I didn't check in the documentation yet, but contrary to what I stated at the date of registering this issue, this won't probably be as trick as it seems: we need to take care of the truncation in the drawing method, but also in the link detection case.

You will probably need to use CTFramesetterSuggestFrameSizeWithConstraints to get the fitRange back, and/or more probably the CTFrameGetVisibleStringRange function. Thus, during drawing, I guess you will need to disable the lineBreakMode, compute the range of the string that will be able to be visible (using the methods mentioned above), then remplace the last visible character by the ellipsis character (and check this ellipsis char is visible, or else remove another one char at the end until it is), before actually drawing this modified string.

Or maybe there is a better, easier way to do this (I didn't check the doc fully either, neither did I google about it), especially I wonder what are the values that can be used in the frameAttributes parameter of the CTFrameRef CTFramesetterCreateFrame method, but obviously it is likely that this would be the place where we expect to provide lineBreaking info… if Apple did actually think about providing such possibility of line truncation?

AliSoftware avatar May 28 '11 11:05 AliSoftware

I also wonder if it is not in the NSAttributedString itself that we need to define the line truncation mode? Maybe forget everything about CTFrame and CTFramesetter for lineBreaing issues, and check the NSAttributedString attributes instead?

AliSoftware avatar May 28 '11 11:05 AliSoftware

Greetings! Is this still an issue? I'm trying to use OHAttributedLabel as the view in my navigationItem's titleView (similar to the iPod app's Now Playing, but two lines instead of three). I'm using kCTCenterTextAlignment and kCTLineBreakByTruncatingTail, then I call sizeToFit. Unfortunately, it doesn't resize to the width I expect, and so the ellipsis kicks in. Trying to figure out a sane way around this ...

jdandrea avatar Jul 28 '11 16:07 jdandrea

Hi @jdandrea

Unfortunately this is still an issue and I didn't have time to check it out so far. Any help is appreciated on this one (even clues) as it does not seem to be as tricky as I thought and won't have much time to care about it until a while.

Thanks!

AliSoftware avatar Jul 28 '11 17:07 AliSoftware

Ahh, understood. For now, I ended up increasing the width by 1.0 and ... the text fits! Of course this may be by chance. I'll have to try it with a few different bits of text and see. Where it might get interesting is if the view ends up being too wide. Not sure if the nav bar ratchets that down a bit automagically.

jdandrea avatar Jul 28 '11 17:07 jdandrea

Actually I think I understood your problem: sizeToFit may resize the label with a smaller width than the original one. E.g. even if you label was originally, say 500px wide but its text is as short as "Hi", sizeToFit will resize it with a width of say 40px. As the text is centered, you may instead call sizeThatFits and get the returned CGSize but only use the height and don't use the width, so that you can keep the same width (wide enough) whatever the text and only adjust the height.

AliSoftware avatar Jul 28 '11 21:07 AliSoftware

Oh! That's an interesting thought. In my case, I'm starting with CGRectZero (perhaps that's the problem right there?). Since this is going into the titleView on a nav bar's nav item, I'm not sure what width would work best. Perhaps I should start with a max width after all, and see if the nav bar ends up adjusting it to fit. Thanks for the tip! I'll check back with results.

jdandrea avatar Jul 29 '11 01:07 jdandrea

I didn't even have to do that. I just kept the width one greater than it already was, and the nav bar does the rest.

jdandrea avatar Aug 02 '11 19:08 jdandrea

Hi

My latest commit includes flooring the values returned by sizeToFit (to avoid subpixelling issues pointed out by issue #28) but also adding a +1 margin for the width to. Don't hesitate to test with this new commit/version

AliSoftware avatar Aug 02 '11 20:08 AliSoftware

Thanks! That worked. :)

jdandrea avatar Aug 04 '11 17:08 jdandrea

Wooops closed the issue by mistake (your sizeToFit issue is solved/closed by my last comment... but this issue #3 itself is related to "UILineBreakMode*Truncation" and should not be closed). Reopening.

AliSoftware avatar Aug 18 '11 10:08 AliSoftware

Is there a workaround for this? I'd like to prevent the warning from showing up in my log - any line break mode is fine for me, as long as there's no warning in my debug console. :)

joshuatbrown avatar Sep 25 '12 21:09 joshuatbrown

@joshuatbrown Errr I don't understand your question… if any line break mode is fine for you, why don't you choose any line break mode that is not a UILineBreakModeXXXTrunctation (line UILineBreakModeWordWrap for example) to avoid the warning in the console?

This is even explained in the warning itself!!

"UILineBreakMode...Truncation" lineBreakModes are not yet fully supported [...] To avoid this warning, change this property in your XIB file to another lineBreakMode value

AliSoftware avatar Sep 26 '12 19:09 AliSoftware

My version of OHAttributedLabel does not produce the message with the solution, nor does the solution work. I still get the warning.

joshuatbrown avatar Sep 27 '12 20:09 joshuatbrown

So which version do you use, and which message do you get? Be sure to be up-to-date to have the latest bugfixes and improvements… and the right warning messages. You should at least update to version 2.0.0 which contains many bug fixes and speed & memory optimisations.


The warning in the latest versions always display the part "UILineBreakMode...Truncation" lineBreakModes are not yet fully supported [...] anyway, to let you know that the Truncation lineBreakModeyou use is unsupported and that you should use another, supported value.
This part seems pretty clear to me, that is you have this warning this is because you use an unsupported …Truncation value for this property (and that the truncation of your text will not behave as expected if you keep this value, as CoreText does not support this configuration). And that if you want to get rid of the warning you should use a supported value (instead of an unsupported one and risk unwanted behavior).
If it does not seem clear to you, please suggest how you would have explained it!

The part "change this property in your XIB file" in the warning is only displayed if your OHAttributedLabel instance has been created using a XIB (or storyboard) and not via code, as it is common to forget to change those properties in the Inspector Panel when you design your XIB, and is just an additional hint. Of course if you created your OHAttributedLabelby code, you will have to change the lineBreakMode property by code to set it ton a supported value (and you probably have a line that change the lineBreakMode of your OHAttributedLabel already in your code — but with an unsupported value —, as the default value when you don't set it is to Word Wrap and not Truncate, if I remember correctly?)

AliSoftware avatar Sep 27 '12 22:09 AliSoftware

I haven't tried this in all cases, but something like this should work for trailing:

NSAttributedString *string = self.attributedString;
CGContextRef context = UIGraphicsGetCurrentContext();
CGContextSetTextMatrix(context, CGAffineTransformIdentity);

CFAttributedStringRef attributedString = (__bridge CFTypeRef)string;
CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString(attributedString);
CGPathRef path = CGPathCreateWithRect(self.bounds, NULL);
CTFrameRef frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, 0), path, NULL);

BOOL needsTruncation = CTFrameGetVisibleStringRange(frame).length < string.length;
CFArrayRef lines = CTFrameGetLines(frame);
NSUInteger lineCount = CFArrayGetCount(lines);
CGPoint *origins = malloc(sizeof(CGPoint) * lineCount);
CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), origins);

for (NSUInteger i = 0; i < lineCount; i++) {
    CTLineRef line = CFArrayGetValueAtIndex(lines, i);
    CGPoint point = origins[i];
    CGContextSetTextPosition(context, point.x, point.y);

    BOOL truncate = (needsTruncation && (i == lineCount - 1));
    if (!truncate) {
        CTLineDraw(line, context);
    }
    else {
        NSDictionary *attributes = [string attributesAtIndex:string.length-1 effectiveRange:NULL];
        NSAttributedString *token = [[NSAttributedString alloc] initWithString:@"\u2026" attributes:attributes];
        CFAttributedStringRef tokenRef = (__bridge CFAttributedStringRef)token;
        CTLineRef truncationToken = CTLineCreateWithAttributedString(tokenRef);
        double width = CTLineGetTypographicBounds(line, NULL, NULL, NULL) - CTLineGetTrailingWhitespaceWidth(line);
        CTLineRef truncatedLine = CTLineCreateTruncatedLine(line, width-1, kCTLineTruncationEnd, truncationToken);

        if (truncatedLine) { CTLineDraw(truncatedLine, context); }
        else { CTLineDraw(line, context); }

        if (truncationToken) { CFRelease(truncationToken); }
        if (truncatedLine) { CFRelease(truncatedLine); }
    }
}

free(origins);
CGPathRelease(path);
CFRelease(frame);
CFRelease(framesetter);

wbyoung avatar Jan 30 '13 19:01 wbyoung

Thx I'll try that probably this weekend. :+1:

To be honest I was kinda "afraid" to implement layout of CTLines myself, not because of the complexity of the code (pretty basic for standard case), but because I suspected that the CTFrameDraw function makes some more work than just a simple loop, especially for truncation… so I guess that I would need to make this new manual layout handle all possible cases (text alignments, mixed paragraph styles and all) to test if we are handling all tricky cases.

The bad news is that even if we draw the lines ourselves like with your code, we will have a lot more to manage. For example, your code will only manage Tail Truncation, and only if the NSAttributedString does not have ranges (CTRuns) that contains the truncation attribute on some of their paragraphs, etc. Especially, it won't handle middle truncation or head truncation and stuff like that :-/

So that's a step forward, sure (and I'll probably try and integrate it anyway as it's better than nothing), but unfortunately that does only solve the basic case and tail truncation… (Apple when are you gonna fix this?)

AliSoftware avatar Jan 30 '13 20:01 AliSoftware

Hmm, I can't figure out how to set the lineBreakMode property in a storyboard. Can anyone advise?

(I'm sure it'll be very obvious!)

mflint avatar Jul 02 '13 11:07 mflint

@mflint UILabel Inspector Panel

AliSoftware avatar Jul 02 '13 20:07 AliSoftware

Thank-you @AliSoftware! I'd added the view to the storyboard as a "View", not as a "Label", so those properties were not available.

mflint avatar Jul 04 '13 09:07 mflint

I integrated wbyoung's solution into OHAttributedLabel drawTextInRect: method if anyone is interested:

- (void)drawTextInRect:(CGRect)aRect
{
    if (_attributedText)
    {
    CGContextRef ctx = UIGraphicsGetCurrentContext();
    CGContextSaveGState(ctx);

    // flipping the context to draw core text
    // no need to flip our typographical bounds from now on
    CGContextConcatCTM(ctx, CGAffineTransformScale(CGAffineTransformMakeTranslation(0, self.bounds.size.height), 1.f, -1.f));

    if (self.shadowColor)
    {
        CGContextSetShadowWithColor(ctx, self.shadowOffset, 0.0, self.shadowColor.CGColor);
    }

    [self recomputeLinksInTextIfNeeded];
    NSAttributedString* attributedStringToDisplay = _attributedTextWithLinks;
    if (self.highlighted && self.highlightedTextColor != nil)
    {
        NSMutableAttributedString* mutAS = [attributedStringToDisplay mutableCopy];
        [mutAS setTextColor:self.highlightedTextColor];
        attributedStringToDisplay = mutAS;
        (void)MRC_AUTORELEASE(mutAS);
    }
    if (textFrame == NULL)
    {
        CFAttributedStringRef cfAttrStrWithLinks = (BRIDGE_CAST CFAttributedStringRef)attributedStringToDisplay;
        CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString(cfAttrStrWithLinks);
        drawingRect = self.bounds;
        if (self.centerVertically || self.extendBottomToFit)
        {
            CGSize sz = CTFramesetterSuggestFrameSizeWithConstraints(framesetter,CFRangeMake(0,0),NULL,CGSizeMake(drawingRect.size.width,CGFLOAT_MAX),NULL);
            if (self.extendBottomToFit)
            {
                CGFloat delta = MAX(0.f , ceilf(sz.height - drawingRect.size.height))+ 10 /* Security margin */;
                drawingRect.origin.y -= delta;
                drawingRect.size.height += delta;
            }
            if (self.centerVertically) {
                drawingRect.origin.y -= (drawingRect.size.height - sz.height)/2;
            }
        }
        CGMutablePathRef path = CGPathCreateMutable();
        CGPathAddRect(path, NULL, drawingRect);
        CFRange fullStringRange = CFRangeMake(0, CFAttributedStringGetLength(cfAttrStrWithLinks));
        textFrame = CTFramesetterCreateFrame(framesetter,fullStringRange, path, NULL);
        CGPathRelease(path);
        CFRelease(framesetter);
    }

    // draw highlights for activeLink
    if (_activeLink)
    {
        [self drawActiveLinkHighlightForRect:drawingRect];
    }

    BOOL hasLinkFillColorSelector = [self.delegate respondsToSelector:@selector(attributedLabel:fillColorForLink:underlineStyle:)];
    if (hasLinkFillColorSelector) {
        [self drawInactiveLinkHighlightForRect:drawingRect];
    }

    if (self.truncLastLine) {
        CFArrayRef lines = CTFrameGetLines(textFrame);
        CFIndex count = MIN(CFArrayGetCount(lines),floor(self.size.height/self.font.lineHeight));

        CGPoint *origins = malloc(sizeof(CGPoint)*count);
        CTFrameGetLineOrigins(textFrame, CFRangeMake(0, count), origins);

        // note that we only enumerate to count-1 in here-- we draw the last line separately

        for (CFIndex i = 0; i < count-1; i++)
        {
            // draw each line in the correct position as-is
            CGContextSetTextPosition(ctx, origins[i].x + drawingRect.origin.x, origins[i].y + drawingRect.origin.y);
            CTLineRef line = (CTLineRef)CFArrayGetValueAtIndex(lines, i);
            CTLineDraw(line, ctx);
        }

        // truncate the last line before drawing it
        if (count) {
            CGPoint lastOrigin = origins[count-1];
            CTLineRef lastLine = CFArrayGetValueAtIndex(lines, count-1);

            // truncation token is a CTLineRef itself
            CFRange effectiveRange;
            CFDictionaryRef stringAttrs = CFAttributedStringGetAttributes((BRIDGE_CAST CFAttributedStringRef)_attributedTextWithLinks, 0, &effectiveRange);

            CFAttributedStringRef truncationString = CFAttributedStringCreate(NULL, CFSTR("\u2026"), stringAttrs);
            CTLineRef truncationToken = CTLineCreateWithAttributedString(truncationString);
            CFRelease(truncationString);

            // now create the truncated line -- need to grab extra characters from the source string,
            // or else the system will see the line as already fitting within the given width and
            // will not truncate it.

            // range to cover everything from the start of lastLine to the end of the string
            CFRange rng = CFRangeMake(CTLineGetStringRange(lastLine).location, 0);
            rng.length = CFAttributedStringGetLength((BRIDGE_CAST CFAttributedStringRef)_attributedTextWithLinks) - rng.location;

            // substring with that range
            CFAttributedStringRef longString = CFAttributedStringCreateWithSubstring(NULL, (BRIDGE_CAST CFAttributedStringRef)_attributedTextWithLinks, rng);
            // line for that string
            CTLineRef longLine = CTLineCreateWithAttributedString(longString);
            CFRelease(longString);

            CTLineRef truncated = CTLineCreateTruncatedLine(longLine, drawingRect.size.width, kCTLineTruncationEnd, truncationToken);
            CFRelease(longLine);
            CFRelease(truncationToken);

            // if 'truncated' is NULL, then no truncation was required to fit it
            if (truncated == NULL){
                truncated = (CTLineRef)CFRetain(lastLine);
            }

            // draw it at the same offset as the non-truncated version
            CGContextSetTextPosition(ctx, lastOrigin.x + drawingRect.origin.x, lastOrigin.y + drawingRect.origin.y);
            CTLineDraw(truncated, ctx);
            CFRelease(truncated);
        }
        free(origins);
        }
         else{
            CTFrameDraw(textFrame, ctx);
         }

        CGContextRestoreGState(ctx);
    } else {
        [super drawTextInRect:aRect];
        }
}

eventomer avatar Mar 20 '14 10:03 eventomer