Decrease the width of the last line in multiline UILabel
Asked Answered
Q

6

20

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

Quatrain answered 27/2, 2013 at 13:36 Comment(7)
I think you need to use a 'UIWebview' and load your custom html in order to accomplish thisSarajane
Ok, I really don't want to do that. Seems kind of an ugly solution doing it that way. I know I can size an UILabel and truncate its tail... worst case even an UITextView.. but not an UIWebView.Quatrain
Where in Apple's AppStore do you see what you've pictured? What I see is the label ending in an ellipsis, and "...More" underneath the text, probably in a different label.Bandwagon
In my example its the Swedish AppStore for iBooks.Quatrain
Yeah, I see that on the American store for iBooks as well.Bandwagon
TTTAttributedLabel, see #17084117Tourer
https://mcmap.net/q/620863/-decrease-the-width-of-the-last-line-in-multiline-uilabel please explain help me, symbol "a" that mean?Fidget
B
13

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;
}
Bandwagon answered 27/2, 2013 at 17:31 Comment(11)
Hi rdelmar, why kNumberOfLines is needed? can this get from numberOfLinesNeeded method? Thanks.Shopworn
@LetBulletFlies, they're different things. numberOfLinesNeeded calculates how many lines you would need to not have the string truncated. kNumberOfLines is the number of lines you want to have in your label, which would presumably be some fixed number.Bandwagon
For some reason, I get an SIGABRT when the class is loaded. I think its due to that I linked the description cell to (formally) an UILabel in Storyboard, which is now an RDLabel subclass of UILabel.Quatrain
@PaulPeelen, I don't know why that would matter. You could try doing a Clean on the project and see if that helps.Bandwagon
yeah, just thinking of that. I just get the error [UILabel setTruncatingText:forNumberOfLines:] which is weird since I subclassed it with your RDLabel. Haven't had my morning coffee yet, so I'll poor me one and that should solve the problem easily ;)Quatrain
@PaulPeelen, you've imported the RDLabel.h into your class where you call that method?Bandwagon
Its better to continue this discussion in chatQuatrain
Turned out I forgot to add RDLabel.m to the correct target in my project, hence it used the UILabel instead. Your code works awesomely great!Quatrain
Just wanted to point out that doing it all at the higher UILabel level is significantly slower than going the CoreText route. I've found that when doing work that needs a lot of formatting, it's not a bad idea to consider CoreText.Taxis
@Rikkles, thanks for the comment. I'll have to check that out. I haven't explored CoreText yet.Bandwagon
@PaulPeelen, I got this to work in the context of a table view. I took out the code that changes the frame, that should happen automatically when the cell expands if the constraints are set up correctly. I added a delegate method, -(void)textWasExpanded:(int)labelTag needsRows:(int) rows to tell the table which row needs expanding, and how many rows it needs.Bandwagon
C
6

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.

Cicelycicenia answered 5/12, 2015 at 4:45 Comment(2)
Nice! Thanks for sharing. I will more like use it in the future, and then test it as well.Quatrain
See my modification below for a more performant method to trim the originalString (helps scroll performance in table views.)Paratrooper
T
4

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.

Taxis answered 27/2, 2013 at 14:3 Comment(4)
Thanks. Seems kind of complex.. but there is no otherway to try this... other then just doing it. I'll give it a go.Quatrain
Your code doesn't work. The addAttribute method is not recognized by NSAttributedString. Get the error No visible @interface for 'NSAttributedString' declares the selector 'addAttribute:value:range:'Quatrain
I have tried adapting your code it bit, but it crashes with EXC_BAD_ACCESS on your last like CTLineGetStringRange, which I can't seem to solve. I'd like some more options if anyone has any.Quatrain
Sorry about that, fixed. Also as I said, you might want to go CoreText all the way if you've started this route.Taxis
G
3

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
}
}
Gaylord answered 14/6, 2018 at 4:29 Comment(0)
D
1

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

Divestiture answered 29/5, 2015 at 11:44 Comment(0)
P
1

@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
    }
}
Paratrooper answered 10/7, 2018 at 23:2 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.