Paul Peelen
Paul Peelen

Reputation: 10329

Decrease the width of the last line in multiline UILabel

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

Upvotes: 20

Views: 8770

Answers (6)

Saoud Rizwan
Saoud Rizwan

Reputation: 659

@paul-slm's answer above is what I ended up using, however I found that it is a very intensive process to strip away the last character of a potentially long string one by one until the label fits the required number of lines. Instead it makes more sense to copy over one character at a time from the beginning of the original string to a blank string, until the required number of lines are met. You should also consider not stepping by one character at a time, but by multiple characters at a time, so as to reach the 'sweet spot' sooner. I replaced func getTruncatingText() -> String with the following:

private func getTruncatingText() -> String? {
    guard let originalString = originalString else { return nil }

    if numberOfLinesNeeded(originalString) > collapsedNumberOfLines {
        var truncatedString = ""
        var toyString = originalString
        while numberOfLinesNeeded(truncatedString + ellipsis) != (collapsedNumberOfLines + 1) {
            let toAdd = toyString.startIndex..<toyString.index(toyString.startIndex, offsetBy: 5)
            let toAddString = toyString[toAdd]
            toyString.removeSubrange(toAdd)
            truncatedString.append(String(toAddString))
        }

        while numberOfLinesNeeded(truncatedString + ellipsis) > collapsedNumberOfLines {
            truncatedString.removeSubrange(truncatedString.index(truncatedString.endIndex, offsetBy: -1)..<truncatedString.endIndex)
        }

        truncatedString += ellipsis
        return truncatedString
    } else {
        return originalString
    }
}

Upvotes: 2

Fraser
Fraser

Reputation: 998

Ive just written a UILabel extension in Swift 4, using a binary search to speed up the substring calculation

It was originally based on the solution by @paul-slm but has diverged considerably

extension UILabel {

func getTruncatingText(originalString: String, newEllipsis: String, maxLines: Int?) -> String {

    let maxLines = maxLines ?? self.numberOfLines

    guard maxLines > 0 else {
        return originalString
    }

    guard self.numberOfLinesNeeded(forString: originalString) > maxLines else {
        return originalString
    }

    var truncatedString = originalString

    var low = originalString.startIndex
    var high = originalString.endIndex
    // binary search substring
    while low != high {
        let mid = originalString.index(low, offsetBy: originalString.distance(from: low, to: high)/2)
        truncatedString = String(originalString[..<mid])
        if self.numberOfLinesNeeded(forString: truncatedString + newEllipsis) <= maxLines {
            low = originalString.index(after: mid)
        } else {
            high = mid
        }
    }

    // substring further to try and truncate at the end of a word
    var tempString = truncatedString
    var prevLastChar = "a"
    for _ in 0..<15 {
        if let lastChar = tempString.last {
            if (prevLastChar == " " && String(lastChar) != "") || prevLastChar == "." {
                truncatedString = tempString
                break
            }
            else {
                prevLastChar = String(lastChar)
                tempString = String(tempString.dropLast())
            }
        }
        else {
            break
        }
    }

    return truncatedString + newEllipsis
}

private func numberOfLinesNeeded(forString string: String) -> Int {
    let oneLineHeight = "A".size(withAttributes: [NSAttributedStringKey.font: font]).height
    let totalHeight = self.getHeight(forString: string)
    let needed = Int(totalHeight / oneLineHeight)
    return needed
}

private func getHeight(forString string: String) -> CGFloat {
    return string.boundingRect(
        with: CGSize(width: self.bounds.size.width, height: CGFloat.greatestFiniteMagnitude),
        options: [.usesLineFragmentOrigin, .usesFontLeading],
        attributes: [NSAttributedStringKey.font: font],
        context: nil).height
}
}

Upvotes: 3

Paul Slm
Paul Slm

Reputation: 483

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.

Upvotes: 6

hsusmita
hsusmita

Reputation: 282

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

Upvotes: 1

Rikkles
Rikkles

Reputation: 3372

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.

Upvotes: 4

rdelmar
rdelmar

Reputation: 104082

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;
}

Upvotes: 13

Related Questions