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)?

- 10,073
- 15
- 85
- 168
-
I think you need to use a 'UIWebview' and load your custom html in order to accomplish this – George Feb 27 '13 at 13:59
-
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`. – Paul Peelen Feb 27 '13 at 14:01
-
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. – rdelmar Feb 27 '13 at 15:56
-
In my example its the Swedish AppStore for iBooks. – Paul Peelen Feb 27 '13 at 15:57
-
Yeah, I see that on the American store for iBooks as well. – rdelmar Feb 28 '13 at 00:26
-
1TTTAttributedLabel, see http://stackoverflow.com/questions/17083617/more-button-in-uilabel-like-in-appstore-any-app-description – Míng Oct 10 '13 at 03:24
-
https://stackoverflow.com/a/51275035/7374397 please explain help me, symbol "a" that mean? – NoMOvie Sep 17 '21 at 09:33
6 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;
}

- 103,982
- 12
- 207
- 218
-
Hi rdelmar, why kNumberOfLines is needed? can this get from numberOfLinesNeeded method? Thanks. – LetBulletFlies Feb 28 '13 at 02:38
-
@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. – rdelmar Feb 28 '13 at 04:26
-
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`. – Paul Peelen Feb 28 '13 at 06:32
-
@PaulPeelen, I don't know why that would matter. You could try doing a Clean on the project and see if that helps. – rdelmar Feb 28 '13 at 06:34
-
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 ;) – Paul Peelen Feb 28 '13 at 06:37
-
@PaulPeelen, you've imported the RDLabel.h into your class where you call that method? – rdelmar Feb 28 '13 at 06:39
-
Its better to [continue this discussion in chat](http://chat.stackoverflow.com/rooms/25264/discussion-between-paul-peelen-and-rdelmar) – Paul Peelen Feb 28 '13 at 06:40
-
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! – Paul Peelen Feb 28 '13 at 07:38
-
2Just 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. – Rikkles Feb 28 '13 at 08:14
-
@Rikkles, thanks for the comment. I'll have to check that out. I haven't explored CoreText yet. – rdelmar Feb 28 '13 at 16:12
-
@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. – rdelmar Feb 28 '13 at 16:15
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.

- 403
- 3
- 11
-
Nice! Thanks for sharing. I will more like use it in the future, and then test it as well. – Paul Peelen Dec 06 '15 at 14:47
-
See my [modification below](https://stackoverflow.com/a/51275035/3502608) for a more performant method to trim the `originalString` (helps scroll performance in table views.) – Saoud Rizwan Jul 10 '18 at 23:02
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.

- 3,372
- 1
- 18
- 24
-
Thanks. Seems kind of complex.. but there is no otherway to try this... other then just doing it. I'll give it a go. – Paul Peelen Feb 27 '13 at 14:25
-
1Your 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:'` – Paul Peelen Feb 27 '13 at 15:19
-
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. – Paul Peelen Feb 27 '13 at 15:56
-
Sorry about that, fixed. Also as I said, you might want to go CoreText all the way if you've started this route. – Rikkles Feb 28 '13 at 08:11
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
}
}

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

- 282
- 3
- 16
@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
}
}

- 629
- 10
- 20