Home » Ios » Decrease the width of the last line in multiline UILabel

Decrease the width of the last line in multiline UILabel

Posted by: admin February 24, 2018 Leave a comment

Questions:

I am implemententing a “read more” functionality much like the one in Apple’s AppStore. However, I am using a multiline UILabel. Looking at Apple’s AppStore, how do they decrease the last visible line’s width to fit the “more” text and still truncate the tail (see image)?

iBooks example image from AppStore

Answers:

This seems to work, at least with the limited amount of testing I’ve done. There are two public methods. You can use the shorter one if you have multiple labels all with the same number of lines — just change the kNumberOfLines at the top to match what you want. Use the longer method if you need to pass the number of lines for different labels. Be sure to change the class of the labels you make in IB to RDLabel. Use these methods instead of setText:. These methods expand the height of the label to kNumberOfLines if necessary, and if still truncated, will expand it to fit the whole string on touch. Currently, you can touch anywhere in the label. It shouldn’t be too hard to change that so only touches near the …Mer would cause the expansion.

#import "RDLabel.h"
#define kNumberOfLines 2
#define ellipsis @"...Mer ▾ "

@implementation RDLabel {
    NSString *string;
}

#pragma Public Methods

- (void)setTruncatingText:(NSString *) txt {
    [self setTruncatingText:txt forNumberOfLines:kNumberOfLines];
}

- (void)setTruncatingText:(NSString *) txt forNumberOfLines:(int) lines{
    string = txt;
    self.numberOfLines = 0;
    NSMutableString *truncatedString = [txt mutableCopy];
    if ([self numberOfLinesNeeded:truncatedString] > lines) {
        [truncatedString appendString:ellipsis];
        NSRange range = NSMakeRange(truncatedString.length - (ellipsis.length + 1), 1);
        while ([self numberOfLinesNeeded:truncatedString] > lines) {
            [truncatedString deleteCharactersInRange:range];
            range.location--;
        }
        [truncatedString deleteCharactersInRange:range];  //need to delete one more to make it fit
        CGRect labelFrame = self.frame;
        labelFrame.size.height = [@"A" sizeWithFont:self.font].height * lines;
        self.frame = labelFrame;
        self.text = truncatedString;
        self.userInteractionEnabled = YES;
        UITapGestureRecognizer *tapper = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(expand:)];
        [self addGestureRecognizer:tapper];
    }else{
        CGRect labelFrame = self.frame;
        labelFrame.size.height = [@"A" sizeWithFont:self.font].height * lines;
        self.frame = labelFrame;
        self.text = txt;
    }
}

#pragma Private Methods

-(int)numberOfLinesNeeded:(NSString *) s {
    float oneLineHeight = [@"A" sizeWithFont:self.font].height;
    float totalHeight = [s sizeWithFont:self.font constrainedToSize:CGSizeMake(self.bounds.size.width, CGFLOAT_MAX) lineBreakMode:NSLineBreakByWordWrapping].height;
    return nearbyint(totalHeight/oneLineHeight);
}

-(void)expand:(UITapGestureRecognizer *) tapper {
    int linesNeeded = [self numberOfLinesNeeded:string];
    CGRect labelFrame = self.frame;
    labelFrame.size.height = [@"A" sizeWithFont:self.font].height * linesNeeded;
    self.frame = labelFrame;
    self.text = string;
}

Questions:
Answers:

There are multiple ways to do this, with the most elegant being to use CoreText exclusively since you get complete control over how to display the text.

Here is a hybrid option where we use CoreText to recreate the label, determine where it ends, and then we cut the label text string at the right place.

NSMutableAttributedString *atrStr = [[NSAttributedString alloc] initWithString:label.text];
NSNumber *kern = [NSNumber numberWithFloat:0];
NSRange full = NSMakeRange(0, [atrStr string].length);
[atrStr addAttribute:(id)kCTKernAttributeName value:kern range:full];

CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)atrStr);  

CGMutablePathRef path = CGPathCreateMutable();
CGPathAddRect(path, NULL, label.frame);
CTFrameRef frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, 0), path, NULL);

CFArrayRef lines = CTFrameGetLines(frame);
CTLineRef line = (CTLineRef)CFArrayGetValueAtIndex(lines, label.numberOfLines-1);
CFRange r = CTLineGetStringRange(line);

This gives you the range of the last line of your label text. From there, it’s trivial to cut it up and put the ellipsis where you want.

The first part creates an attributed string with the properties it needs to replicate the behavior of UILabel (might not be 100% but should be close enough).
Then we create a framesetter and frame, and get all the lines of the frame, from which we extract the range of the last expected line of the label.

This is clearly some kind of a hack, and as I said if you want complete control over how your text looks you’re better off with a pure CoreText implementation of that label.

Questions:
Answers:

Since this post is from 2013, I wanted to give my Swift implementation of the very nice solution from @rdelmar.

Considering we are using a SubClass of UILabel:

private let kNumberOfLines = 2
private let ellipsis = " MORE"

private var originalString: String! // Store the original text in the init

private func getTruncatingText() -> String {
    var truncatedString = originalString.mutableCopy() as! String

    if numberOfLinesNeeded(truncatedString) > kNumberOfLines {
        truncatedString += ellipsis

        var range = Range<String.Index>(
            start: truncatedString.endIndex.advancedBy(-(ellipsis.characters.count + 1)),
            end: truncatedString.endIndex.advancedBy(-ellipsis.characters.count)
        )

        while numberOfLinesNeeded(truncatedString) > kNumberOfLines {
            truncatedString.removeRange(range)

            range.startIndex = range.startIndex.advancedBy(-1)
            range.endIndex = range.endIndex.advancedBy(-1)
        }
    }

    return truncatedString
}

private func getHeightForString(str: String) -> CGFloat {
    return str.boundingRectWithSize(
        CGSizeMake(self.bounds.size.width, CGFloat.max),
        options: [.UsesLineFragmentOrigin, .UsesFontLeading],
        attributes: [NSFontAttributeName: font],
        context: nil).height
}

private func numberOfLinesNeeded(s: String) -> Int {
    let oneLineHeight = "A".sizeWithAttributes([NSFontAttributeName: font]).height
    let totalHeight = getHeightForString(s)
    return Int(totalHeight / oneLineHeight)
}

func expend() {
    var labelFrame = self.frame
    labelFrame.size.height = getHeightForString(originalString)
    self.frame = labelFrame
    self.text = originalString
}

func collapse() {
    let truncatedText = getTruncatingText()
    var labelFrame = self.frame
    labelFrame.size.height = getHeightForString(truncatedText)
    self.frame = labelFrame
    self.text = truncatedText
}

Unlike the old solution, this will work as well for any kind of text attribute (like NSParagraphStyleAttributeName).

Please feel free to critic and comment. Thanks again to @rdelmar.

Questions:
Answers:

ResponsiveLabel is a subclass of UILabel which allows to add custom truncation token which responds to touch.