Multi-line NSAttributedString with truncated text

后端 未结 9 1961
情书的邮戳
情书的邮戳 2020-12-23 14:55

I need a UILabel subcass with multiline attributed text with support for links, bold styles, etc. I also need tail truncation with an ellipsis. None of the open source co

相关标签:
9条回答
  • 2020-12-23 15:12

    I used as sample MTLabel. It allows to manage line height. I needed the draw method exactly, so i just put away most stuff i did not need. This method allows me to draw multilined text in rect with tail truncation.

    CGRect CTLineGetTypographicBoundsAsRect(CTLineRef line, CGPoint lineOrigin)
    {
    CGFloat ascent = 0;
    CGFloat descent = 0;
    CGFloat leading = 0;
    CGFloat width = CTLineGetTypographicBounds(line, &ascent, &descent, &leading);
    CGFloat height = ascent + descent;
    
    return CGRectMake(lineOrigin.x,
                      lineOrigin.y - descent,
                      width,
                      height);
    }
    - (void)drawText:(NSString*) text InRect:(CGRect)rect withFont:(UIFont*)aFont inContext:(CGContextRef)context {
    
    if (!text) {
        return;
    }
    
    BOOL _limitToNumberOfLines = YES;
    int _numberOfLines = 2;
    float _lineHeight = 22;
    
    //Create a CoreText font object with name and size from the UIKit one
    CTFontRef font = CTFontCreateWithName((CFStringRef)aFont.fontName ,
                                          aFont.pointSize,
                                          NULL);
    
    
    //Setup the attributes dictionary with font and color
    NSDictionary *attributes = [NSDictionary dictionaryWithObjectsAndKeys:
                                (id)font, (id)kCTFontAttributeName,
                                [UIColor lightGrayColor].CGColor, kCTForegroundColorAttributeName,
                                nil];
    
    NSAttributedString *attributedString = [[[NSAttributedString alloc]
                                             initWithString:text
                                             attributes:attributes] autorelease];
    
    CFRelease(font);
    
    //Create a TypeSetter object with the attributed text created earlier on
    CTTypesetterRef typeSetter = CTTypesetterCreateWithAttributedString((CFAttributedStringRef)attributedString);
    
    //Start drawing from the upper side of view (the context is flipped, so we need to grab the height to do so)
    CGFloat y = self.bounds.origin.y + self.bounds.size.height - rect.origin.y - aFont.ascender;
    
    BOOL shouldDrawAlong = YES;
    int count = 0;
    CFIndex currentIndex = 0;
    
    float _textHeight = 0;
    
    CGContextSaveGState(context);
    
    CGContextSetTextMatrix(context, CGAffineTransformIdentity);
    CGContextTranslateCTM(context, 0, self.bounds.size.height);
    CGContextScaleCTM(context, 1.0, -1.0);
    
    //Start drawing lines until we run out of text
    while (shouldDrawAlong) {
    
        //Get CoreText to suggest a proper place to place the line break
        CFIndex lineLength = CTTypesetterSuggestLineBreak(typeSetter,
                                                          currentIndex,
                                                          rect.size.width);
    
        //Create a new line with from current index to line-break index
        CFRange lineRange = CFRangeMake(currentIndex, lineLength);
        CTLineRef line = CTTypesetterCreateLine(typeSetter, lineRange);
    
        //Check to see if our index didn't exceed the text, and if should limit to number of lines        
        if (currentIndex + lineLength >= [text length])
        {
            shouldDrawAlong = NO;
        }
        else
        {
            if (!(_limitToNumberOfLines && count < _numberOfLines-1))
            {
                int i = 0;
                if ([[[text substringWithRange:NSMakeRange(currentIndex, lineLength)] stringByAppendingString:@"…"] sizeWithFont:aFont].width > rect.size.width)
                {
                    i--;
                    while ([[[text substringWithRange:NSMakeRange(currentIndex, lineLength + i)] stringByAppendingString:@"…"] sizeWithFont:aFont].width > rect.size.width) 
                    {
                        i--;
                    }
                }
                else
                {
                    i++;
                    while ([[[text substringWithRange:NSMakeRange(currentIndex, lineLength + i)] stringByAppendingString:@"…"] sizeWithFont:aFont].width < rect.size.width) 
                    {
                        i++;
                    }
                    i--;
                }
                attributedString = [[[NSAttributedString alloc] initWithString:[[text substringWithRange:NSMakeRange(currentIndex, lineLength + i)] stringByAppendingString:@"…"] attributes:attributes] autorelease];
    
                CFRelease(typeSetter);
    
                typeSetter = CTTypesetterCreateWithAttributedString((CFAttributedStringRef)attributedString);
    
                CFRelease(line);
    
                CFRange lineRange = CFRangeMake(0, 0);
                line = CTTypesetterCreateLine(typeSetter, lineRange);
    
                shouldDrawAlong = NO;
            }
        }
    
    
        CGFloat x = rect.origin.x;
        //Setup the line position
        CGContextSetTextPosition(context, x, y);
        CTLineDraw(line, context);
    
        count++;
        CFRelease(line);
    
        y -= _lineHeight;
    
        currentIndex += lineLength;
        _textHeight += _lineHeight;
    }
    
    CFRelease(typeSetter);
    
    CGContextRestoreGState(context);
    
    }
    
    0 讨论(0)
  • 2020-12-23 15:14

    Hi I am the developer of OHAttributedLabel.

    There is no easy way to achieve this (as explained in the associated issue I opened on the github repository of my project), because CoreText does not offer such feature.

    The only way to do this would be to implement the text layout yourself using CoreText objects (CTLine, etc) instead of using the CTFrameSetter that does this for you (but w/o managing line truncation). The idea would be to build all the CTLines to lay them out (depending on the glyphs in your NSAttributedString it contains and the word wrapping policy) one after the other and manage the ellipsis at the end yourself.

    I would really appreciate if someone propose a solution to do this propery as it seems a bit of work to do and you have to manage a range of special/unusual cases too (emoji cases, fonts with odd metrics and unusual glyphs, vertical alignment, take into account the size of the ellipsis itself at the end to know when to stop).

    So feel free to dig around and try to implement the framing of the lines yourself it would be really appreciated!

    0 讨论(0)
  • Heres a Swift 5 version of @Toydor's answer:

    let attributedString = NSMutableAttributedString(string: "my String")    
    let style: NSMutableParagraphStyle = NSMutableParagraphStyle()
    style.lineBreakMode = .byTruncatingTail
    attributedString.addAttribute(NSAttributedString.Key.paragraphStyle,
                                          value: style,
                                          range: NSMakeRange(0, attributedString.length))
    
    0 讨论(0)
  • 2020-12-23 15:22

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

    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);
    
    0 讨论(0)
  • 2020-12-23 15:26

    You may be able to use follow code to have a more simple solution.

        // last line.
        if (_limitToNumberOfLines && count == _numberOfLines-1)
        {
            // check if we reach end of text.
            if (lineRange.location + lineRange.length < [_text length])
            {
                CFDictionaryRef dict = ( CFDictionaryRef)attributes;
                CFAttributedStringRef truncatedString = CFAttributedStringCreate(NULL, CFSTR("\u2026"), dict);
    
                CTLineRef token = CTLineCreateWithAttributedString(truncatedString);
    
                // not possible to display all text, add tail ellipsis.
                CTLineRef truncatedLine = CTLineCreateTruncatedLine(line, self.bounds.size.width - 20, kCTLineTruncationEnd, token);
                CFRelease(line); line = nil;
                line = truncatedLine;
            }
        }
    

    I'm using MTLabel in my project and it's a really nice solution for my project.

    0 讨论(0)
  • 2020-12-23 15:28

    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];
            }
    }
    
    0 讨论(0)
提交回复
热议问题