Is there an easy way to get (or simply display) the text from a given line in a UILabel?
My UILabel is correctly displaying my text and laying it out beautifully but occasionally I need to be able to just show certain lines but obviously I need to know how UILabel has positioned everything to do this.
I know this could easily be done with a substring but I'd need to know the start and end point of the line.
Alternatively I could scroll the UILabel if there was some kind of offset to the UILabel's frame and hide the rest of the content I didn't want to see.
I've not been able to uncover anything that shows how this could be done easily. Anyone got any good ideas?
Thanks
iphaaw
I have better way to find it.
You can get this with the help of CoreText.framework.
1.Add CoreText.framework.
2.Import #import <CoreText/CoreText.h>. Then use below method:
- (NSArray *)getLinesArrayOfStringInLabel:(UILabel *)label {
NSString *text = [label text];
UIFont *font = [label font];
CGRect rect = [label frame];
CTFontRef myFont = CTFontCreateWithName((__bridge CFStringRef)([font fontName]), [font pointSize], NULL);
NSMutableAttributedString *attStr = [[NSMutableAttributedString alloc] initWithString:text];
[attStr addAttribute:(NSString *)kCTFontAttributeName value:(__bridge id)myFont range:NSMakeRange(0, attStr.length)];
CTFramesetterRef frameSetter = CTFramesetterCreateWithAttributedString((__bridge CFAttributedStringRef)attStr);
CGMutablePathRef path = CGPathCreateMutable();
CGPathAddRect(path, NULL, CGRectMake(0,0,rect.size.width,100000));
CTFrameRef frame = CTFramesetterCreateFrame(frameSetter, CFRangeMake(0, 0), path, NULL);
NSArray *lines = (__bridge NSArray *)CTFrameGetLines(frame);
NSMutableArray *linesArray = [[NSMutableArray alloc]init];
for (id line in lines)
{
CTLineRef lineRef = (__bridge CTLineRef )line;
CFRange lineRange = CTLineGetStringRange(lineRef);
NSRange range = NSMakeRange(lineRange.location, lineRange.length);
NSString *lineString = [text substringWithRange:range];
[linesArray addObject:lineString];
}
return (NSArray *)linesArray;
}
Call this method :-
NSArray *linesArray = [self getLinesArrayOfStringInLabel:yourLabel];
Now you can use linesArray.
SWIFT 4 VERSION
func getLinesArrayOfString(in label: UILabel) -> [String] {
/// An empty string's array
var linesArray = [String]()
guard let text = label.text, let font = label.font else {return linesArray}
let rect = label.frame
let myFont = CTFontCreateWithFontDescriptor(font.fontDescriptor, 0, nil)
let attStr = NSMutableAttributedString(string: text)
attStr.addAttribute(kCTFontAttributeName as NSAttributedString.Key, value: myFont, range: NSRange(location: 0, length: attStr.length))
let frameSetter: CTFramesetter = CTFramesetterCreateWithAttributedString(attStr as CFAttributedString)
let path: CGMutablePath = CGMutablePath()
path.addRect(CGRect(x: 0, y: 0, width: rect.size.width, height: 100000), transform: .identity)
let frame: CTFrame = CTFramesetterCreateFrame(frameSetter, CFRangeMake(0, 0), path, nil)
guard let lines = CTFrameGetLines(frame) as? [Any] else {return linesArray}
for line in lines {
let lineRef = line as! CTLine
let lineRange: CFRange = CTLineGetStringRange(lineRef)
let range = NSRange(location: lineRange.location, length: lineRange.length)
let lineString: String = (text as NSString).substring(with: range)
linesArray.append(lineString)
}
return linesArray
}
Use:
let lines: [String] = getLinesArrayOfString(in: label)
Swift 3
func getLinesArrayFromLabel(label:UILabel) -> [String] {
let text:NSString = label.text! as NSString // TODO: Make safe?
let font:UIFont = label.font
let rect:CGRect = label.frame
let myFont:CTFont = CTFontCreateWithName(font.fontName as CFString, font.pointSize, nil)
let attStr:NSMutableAttributedString = NSMutableAttributedString(string: text as String)
attStr.addAttribute(String(kCTFontAttributeName), value:myFont, range: NSMakeRange(0, attStr.length))
let frameSetter:CTFramesetter = CTFramesetterCreateWithAttributedString(attStr as CFAttributedString)
let path:CGMutablePath = CGMutablePath()
path.addRect(CGRect(x:0, y:0, width:rect.size.width, height:100000))
let frame:CTFrame = CTFramesetterCreateFrame(frameSetter, CFRangeMake(0, 0), path, nil)
let lines = CTFrameGetLines(frame) as NSArray
var linesArray = [String]()
for line in lines {
let lineRange = CTLineGetStringRange(line as! CTLine)
let range:NSRange = NSMakeRange(lineRange.location, lineRange.length)
let lineString = text.substring(with: range)
linesArray.append(lineString as String)
}
return linesArray
}
Swift 2 (Xcode 7) version (tested, and re-edited from the Swift 1 answer)
func getLinesArrayOfStringInLabel(label:UILabel) -> [String] {
let text:NSString = label.text! // TODO: Make safe?
let font:UIFont = label.font
let rect:CGRect = label.frame
let myFont:CTFontRef = CTFontCreateWithName(font.fontName, font.pointSize, nil)
let attStr:NSMutableAttributedString = NSMutableAttributedString(string: text as String)
attStr.addAttribute(String(kCTFontAttributeName), value:myFont, range: NSMakeRange(0, attStr.length))
let frameSetter:CTFramesetterRef = CTFramesetterCreateWithAttributedString(attStr as CFAttributedStringRef)
let path:CGMutablePathRef = CGPathCreateMutable()
CGPathAddRect(path, nil, CGRectMake(0, 0, rect.size.width, 100000))
let frame:CTFrameRef = CTFramesetterCreateFrame(frameSetter, CFRangeMake(0, 0), path, nil)
let lines = CTFrameGetLines(frame) as NSArray
var linesArray = [String]()
for line in lines {
let lineRange = CTLineGetStringRange(line as! CTLine)
let range:NSRange = NSMakeRange(lineRange.location, lineRange.length)
let lineString = text.substringWithRange(range)
linesArray.append(lineString as String)
}
return linesArray
}
Answer with Proper release !!!!
-(NSArray *)getLinesArrayOfStringInLabel:(UILabel *)label
{
NSString *text = [label text];
UIFont *font = [label font];
CGRect rect = [label frame];
CTFontRef myFont = CTFontCreateWithName(( CFStringRef)([font fontName]), [font pointSize], NULL);
NSMutableAttributedString *attStr = [[NSMutableAttributedString alloc] initWithString:text];
[attStr addAttribute:(NSString *)kCTFontAttributeName value:( id)myFont range:NSMakeRange(0, attStr.length)];
CFRelease(myFont);
CTFramesetterRef frameSetter = CTFramesetterCreateWithAttributedString(( CFAttributedStringRef)attStr);
CGMutablePathRef path = CGPathCreateMutable();
CGPathAddRect(path, NULL, CGRectMake(0,0,rect.size.width,100000));
CTFrameRef frame = CTFramesetterCreateFrame(frameSetter, CFRangeMake(0, 0), path, NULL);
NSArray *lines = ( NSArray *)CTFrameGetLines(frame);
NSMutableArray *linesArray = [[NSMutableArray alloc]init];
for (id line in lines)
{
CTLineRef lineRef = ( CTLineRef )line;
CFRange lineRange = CTLineGetStringRange(lineRef);
NSRange range = NSMakeRange(lineRange.location, lineRange.length);
NSString *lineString = [text substringWithRange:range];
CFAttributedStringSetAttribute((CFMutableAttributedStringRef)attStr, lineRange, kCTKernAttributeName, (CFTypeRef)([NSNumber numberWithFloat:0.0]));
CFAttributedStringSetAttribute((CFMutableAttributedStringRef)attStr, lineRange, kCTKernAttributeName, (CFTypeRef)([NSNumber numberWithInt:0.0]));
//NSLog(#"''''''''''''''''''%#",lineString);
[linesArray addObject:lineString];
}
[attStr release];
CGPathRelease(path);
CFRelease( frame );
CFRelease(frameSetter);
return (NSArray *)linesArray;
}
Very important change regarding iOS 11+
Starting with iOS 11, Apple intentionally changed the behaviour of their word-wrapping feature for UILabel which effects detecting the String contents of individual lines in a multiline UILabel. By design, the word-wrapping of the UILabel now avoids orphaned text (single words in a new line), as discussed here: word wrapping in iOS 11
Because of that, the way CTFrameGetLines(frame) returns the CTLine array of all lines in the label no longer works correctly if the new word-wrapping that avoids orphaned text takes effect in a particular line. To the contrary, it results in parts of the String that by the new word wrapping design would belong to the next line instead end up in the line in focus.
A tested fix for this problem can be found in my altered version of #TheTiger's answer, which makes use of calculating the actual content size of the UILabel using sizeThatFits(size:), before using that size to create the rect / path written in Swift 4:
extension UILabel {
/// creates an array containing one entry for each line of text the label has
var lines: [String]? {
guard let text = text, let font = font else { return nil }
let attStr = NSMutableAttributedString(string: text)
attStr.addAttribute(NSAttributedString.Key.font, value: font, range: NSRange(location: 0, length: attStr.length))
let frameSetter = CTFramesetterCreateWithAttributedString(attStr as CFAttributedString)
let path = CGMutablePath()
// size needs to be adjusted, because frame might change because of intelligent word wrapping of iOS
let size = sizeThatFits(CGSize(width: self.frame.width, height: .greatestFiniteMagnitude))
path.addRect(CGRect(x: 0, y: 0, width: size.width, height: size.height), transform: .identity)
let frame = CTFramesetterCreateFrame(frameSetter, CFRangeMake(0, attStr.length), path, nil)
guard let lines = CTFrameGetLines(frame) as? [Any] else { return nil }
var linesArray: [String] = []
for line in lines {
let lineRef = line as! CTLine
let lineRange = CTLineGetStringRange(lineRef)
let range = NSRange(location: lineRange.location, length: lineRange.length)
let lineString = (text as NSString).substring(with: range)
linesArray.append(lineString)
}
return linesArray
}
}
This UILabel extension returns the contents of the label as a String array with one entry per line exactly as presented to the eye of the user.
I don't think there's a native way for doing this (like a "takethenline" method).
I can figure out a tricky solution but I'm not sure is the best one.
You could split your label into an array of words.
Then you could loop the array and check the text height until that word like this:
NSString *texttocheck;
float old_height = 0;
int linenumber = 0;
for (x=0; x<[wordarray lenght]; x++) {
texttocheck = [NSString stringWithFormat:#"%# %#", texttocheck, [wordarray objectAtIndex:x]];
float height = [text sizeWithFont:textLabel.font
constrainedToSize:CGSizeMake(textLabel.bounds.size.width,99999)
lineBreakMode:UILineBreakModeWordWrap].height;
if (old_height < height) {
linenumber++;
}
}
If height changes, it means there's a line break before the word.
I can't check if the syntax is written correctly now, so you have to check it yourself.
This is the Swift 3 version for getting all the lines in the label. (#fredpi has a similar answer but it's only for the first line)
extension UILabel {
func getArrayOfLinesInLabel() -> [String] {
let text = NSString(string: self.text ?? "-- -- -- --")
let font = self.font ?? // Your default font here
let rect = self.frame
let myFont = CTFontCreateWithName(font.fontName as CFString?, font.pointSize, nil)
let attStr = NSMutableAttributedString(string: text as String)
attStr.addAttribute(String(kCTFontAttributeName), value:myFont, range: NSRange(location: 0, length: attStr.length))
let frameSetter = CTFramesetterCreateWithAttributedString(attStr as CFAttributedString)
let path = CGPath(rect: CGRect(x: 0, y: 0, width: rect.size.width, height: rect.size.height), transform: nil)
let frame = CTFramesetterCreateFrame(frameSetter, CFRangeMake(0, 0), path, nil)
guard let lines = CTFrameGetLines(frame) as? [CTLine] else {
return []
}
var linesArray = [String]()
for line in lines {
let lineRange = CTLineGetStringRange(line)
let range = NSRange(location: lineRange.location, length: lineRange.length)
let lineString = text.substring(with: range)
linesArray.append(lineString as String)
}
return linesArray
}
}
Swift 3 – Xcode 8.1
I've put together code from the previous answers to create a Swift 3, Xcode 8.1-compatible extension to UILabel returning the first line of the label.
import CoreText
extension UILabel {
/// Returns the String displayed in the first line of the UILabel or "" if text or font is missing
var firstLineString: String {
guard let text = self.text else { return "" }
guard let font = self.font else { return "" }
let rect = self.frame
let attStr = NSMutableAttributedString(string: text)
attStr.addAttribute(String(kCTFontAttributeName), value: CTFontCreateWithName(font.fontName as CFString, font.pointSize, nil), range: NSMakeRange(0, attStr.length))
let frameSetter = CTFramesetterCreateWithAttributedString(attStr as CFAttributedString)
let path = CGMutablePath()
path.addRect(CGRect(x: 0, y: 0, width: rect.size.width + 7, height: 100))
let frame = CTFramesetterCreateFrame(frameSetter, CFRangeMake(0, 0), path, nil)
guard let line = (CTFrameGetLines(frame) as! [CTLine]).first else { return "" }
let lineString = text[text.startIndex...text.index(text.startIndex, offsetBy: CTLineGetStringRange(line).length-2)]
return lineString
}
}
To use it, simple call firstLineString on your UILabel instance like this:
let firstLine = myLabel.firstLineString
The accepted answer is very good.
I refactored two places:
changed 10000 to CGFloat.greatestFiniteMagnitude
Added it to an extension of UILabel
I also want to mention, if you create the label by setting the frame it works fine. If you use autolayout then dont forgot to call
youLabel.layoutIfNeeded()
to get correct frame size.
Here is the code:
extension UILabel {
var stringLines: [String] {
guard let text = text, let font = font else { return [] }
let ctFont = CTFontCreateWithName(font.fontName as CFString, font.pointSize, nil)
let attStr = NSMutableAttributedString(string: text)
attStr.addAttribute(kCTFontAttributeName as NSAttributedString.Key, value: ctFont, range: NSRange(location: 0, length: attStr.length))
let frameSetter = CTFramesetterCreateWithAttributedString(attStr as CFAttributedString)
let path = CGMutablePath()
path.addRect(CGRect(x: 0, y: 0, width: self.frame.size.width, height: CGFloat.greatestFiniteMagnitude), transform: .identity)
let frame = CTFramesetterCreateFrame(frameSetter, CFRangeMake(0, 0), path, nil)
guard let lines = CTFrameGetLines(frame) as? [Any] else { return [] }
return lines.map { line in
let lineRef = line as! CTLine
let lineRange: CFRange = CTLineGetStringRange(lineRef)
let range = NSRange(location: lineRange.location, length: lineRange.length)
return (text as NSString).substring(with: range)
}
}
}
If all your characters are displayed in the same size, i.e. they're enclosed in a box of common size, you can exploit that. (This seems to be the case with Japanese characters, for example.)
Otherwise you can query the size of each character in the display font and calculate what the line would have to be.
The only worry then is that your calculation might disagree with what Apple's doing behind the scenes - in which case, I recommend you go to the trouble of overriding the text frame drawing. Look up Core Text in the documents for this.
(I may have been doing this wrong, but I didn't find Apple's method as given in the docs was very accurate, so I did something else myself.)
Sorry, my reputation is too low to place a comment.
This is a comment to https://stackoverflow.com/a/53783203/2439941 from Philipp Jahoda.
Your code snippet worked flawless, until we enabled Dynamic Type on the UILabel. When we set the text size to the largest value in the iOS Settings app, it started to miss characters in the last line of the returned array. Or even missing the last line completely with a significant amount of text.
We managed to resolve this by using a different way to get frame:
let frameSetter = CTFramesetterCreateWithAttributedString(attStr as CFAttributedString)
let path = UIBezierPath(rect: CGRect(x: 0, y: 0, width: self.frame.width, height: .greatestFiniteMagnitude))
let frame = CTFramesetterCreateFrame(frameSetter, CFRangeMake(0, attStr.length), path.cgPath, nil)
guard let lines = CTFrameGetLines(frame) as? [Any] else { return nil }
Now it works correctly for any Dynamic Type size.
The complete function is then:
extension UILabel {
/// creates an array containing one entry for each line of text the label has
var lines: [String]? {
guard let text = text, let font = font else { return nil }
let attStr = NSMutableAttributedString(string: text)
attStr.addAttribute(NSAttributedString.Key.font, value: font, range: NSRange(location: 0, length: attStr.length))
let frameSetter = CTFramesetterCreateWithAttributedString(attStr as CFAttributedString)
let path = UIBezierPath(rect: CGRect(x: 0, y: 0, width: self.frame.width, height: .greatestFiniteMagnitude))
let frame = CTFramesetterCreateFrame(frameSetter, CFRangeMake(0, attStr.length), path.cgPath, nil)
guard let lines = CTFrameGetLines(frame) as? [Any] else { return nil }
var linesArray: [String] = []
for line in lines {
let lineRef = line as! CTLine
let lineRange = CTLineGetStringRange(lineRef)
let range = NSRange(location: lineRange.location, length: lineRange.length)
let lineString = (text as NSString).substring(with: range)
linesArray.append(lineString)
}
return linesArray
}
}
Related
I want to get first two lines text from UIlabel.I have searched a lot but not find any solution.Please tell me how to get first two lines text.
Use this extension check if your label's number of line granter then 2 or whatever you target then apply this:
extension UILabel {
func addTrailing(with trailingText: String, moreText: String, moreTextFont: UIFont, moreTextColor: UIColor) {
let readMoreText: String = trailingText + moreText
let lengthForVisibleString: Int = self.vissibleTextLength()
let mutableString: String = self.text!
let trimmedString: String? = (mutableString as NSString).replacingCharacters(in: NSRange(location: lengthForVisibleString, length: ((self.text?.characters.count)! - lengthForVisibleString)), with: "")
let readMoreLength: Int = (readMoreText.characters.count)
let trimmedForReadMore: String = (trimmedString! as NSString).replacingCharacters(in: NSRange(location: ((trimmedString?.characters.count ?? 0) - readMoreLength), length: readMoreLength), with: "") + trailingText
let answerAttributed = NSMutableAttributedString(string: trimmedForReadMore, attributes: [NSFontAttributeName: self.font])
let readMoreAttributed = NSMutableAttributedString(string: moreText, attributes: [NSFontAttributeName: moreTextFont, NSForegroundColorAttributeName: moreTextColor])
answerAttributed.append(readMoreAttributed)
self.attributedText = answerAttributed
}
func vissibleTextLength() -> Int {
let font: UIFont = self.font
let mode: NSLineBreakMode = self.lineBreakMode
let labelWidth: CGFloat = self.frame.size.width
let labelHeight: CGFloat = self.frame.size.height
let sizeConstraint = CGSize(width: labelWidth, height: CGFloat.greatestFiniteMagnitude)
let attributes: [AnyHashable: Any] = [NSFontAttributeName: font]
let attributedText = NSAttributedString(string: self.text!, attributes: attributes as? [String : Any])
let boundingRect: CGRect = attributedText.boundingRect(with: sizeConstraint, options: .usesLineFragmentOrigin, context: nil)
if boundingRect.size.height > labelHeight {
var index: Int = 0
var prev: Int = 0
let characterSet = CharacterSet.whitespacesAndNewlines
repeat {
prev = index
if mode == NSLineBreakMode.byCharWrapping {
index += 1
} else {
index = (self.text! as NSString).rangeOfCharacter(from: characterSet, options: [], range: NSRange(location: index + 1, length: self.text!.characters.count - index - 1)).location
}
} while index != NSNotFound && index < self.text!.characters.count && (self.text! as NSString).substring(to: index).boundingRect(with: sizeConstraint, options: .usesLineFragmentOrigin, attributes: attributes as? [String : Any], context: nil).size.height <= labelHeight
return prev
}
return self.text!.characters.count
}
}
Count Label's number of line:
func countLabelLines(label: UILabel) -> Int {
// Call self.layoutIfNeeded() if your view uses auto layout
self.view.layoutIfNeeded()
let myText = label.text! as NSString
let rect = CGSize(width: label.bounds.width, height: CGFloat.greatestFiniteMagnitude)
let labelSize = myText.boundingRect(with: rect, options: .usesLineFragmentOrigin, attributes: [NSFontAttributeName: label.font], context: nil)
return Int(ceil(CGFloat(labelSize.height) / label.font.lineHeight))
}
Example:
if self.countLabelLines(label: lblmedicationDetailText) >= numberOflines{
let readmoreFont = UIFont(name: "Montserrat-Regular", size: 15.0)
let readmoreFontColor = UIColor.init(red: 1.0/255.0, green: 100.0/255.0, blue: 157.0/255.0, alpha: 1.0)
DispatchQueue.main.async {
self.lblmedicationDetailText.addTrailing(with: "... ", moreText: "Read More", moreTextFont: readmoreFont!, moreTextColor: readmoreFontColor)
}
}
let numOfLine = self.numberOfLinesInLabel(self.testingLabel.text!, labelWidth: self.testingLabel.frame.width, labelHeight: self.testingLabel.frame.height, font: self.testingLabel.font)
self.getNumberOfLineDict.setValue(numOfLine, forKey: String(i))
let Lines : Int = getNumberOfLineDict.valueForKey(String(indexPath.row)) as! Int // I did it in tableview. So I used indexPath.row
if Lines > 2
{
ReadmoreBtn.isHidden = false
}
else
{
ReadmoreBtn.isHidden = true
}
To get the number of lines in a text
func numberOfLinesInLabel(yourString: String, labelWidth: CGFloat, labelHeight: CGFloat, font: UIFont) -> Int {
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.minimumLineHeight = labelHeight
paragraphStyle.maximumLineHeight = labelHeight
paragraphStyle.lineBreakMode = .ByWordWrapping
let attributes: [String: AnyObject] = [NSFontAttributeName: font, NSParagraphStyleAttributeName: paragraphStyle]
let constrain = CGSizeMake(labelWidth, CGFloat(Float.infinity))
let size = yourString.sizeWithAttributes(attributes)
let stringWidth = size.width
let numberOfLines = ceil(Double(stringWidth/constrain.width))
return Int(numberOfLines)
}
My advice is doing something on UI only.
You can set a UIView(such as it has gradual clear to white view and has a label "View more...") to cover the original label except first two lines.
You can get the first two lines height by label.font.lineHeight * 2.
I am trying to create a bullet list using NSAttributedString and UITextView. And, here is what I was able to achieve so far:
As one can see, there is a small "shift" between two lines. Here is a fragment of code which I use to build attributed string:
func add(bulletList strings: [String],
indentation: CGFloat = 15,
lineSpacing: CGFloat = 3,
paragraphSpacing: CGFloat = 10) {
func createParagraphAttirbute() -> NSParagraphStyle {
var paragraphStyle: NSMutableParagraphStyle
let nonOptions = NSDictionary() as! [NSTextTab.OptionKey: Any]
paragraphStyle = NSParagraphStyle.default.mutableCopy() as! NSMutableParagraphStyle
paragraphStyle.tabStops = [
NSTextTab(textAlignment: .left, location: indentation, options: nonOptions)]
paragraphStyle.defaultTabInterval = indentation
paragraphStyle.firstLineHeadIndent = 0
paragraphStyle.lineSpacing = lineSpacing
paragraphStyle.paragraphSpacing = paragraphSpacing
paragraphStyle.headIndent = indentation
return paragraphStyle
}
var buffer = NSMutableAttributedString.init()
for string in strings {
let formattedString = "\u{2022} \(string)\n"
let attributedString = NSMutableAttributedString(string: formattedString)
let paragraphStyle = createParagraphAttirbute()
attributedString.addAttributes(
[NSAttributedStringKey.paragraphStyle : paragraphStyle],
range: NSMakeRange(0, attributedString.length))
attributedString.addAttributes(
textAttributes,
range: NSMakeRange(0, attributedString.length))
let string:NSString = NSString(string: formattedString)
let rangeForBullet:NSRange = string.range(of: bulletPoint)
attributedString.addAttributes(bulletAttirbutes, range: rangeForBullet)
buffer.append(attributedString)
}
}
Do you think something is wrong with selected paragraph parameters? Because the code does almost what is expected, excluding this gap.
Update 1
Following #the4kman advice, I've changed provided code like this:
paragraphStyle.firstLineHeadIndent = indentation
But now I have all lines aligned with each other, including the bullet point:
Update 2
Ok, the solution was quite simple - replace space with tab. See updated code below.
#the4kman, #Krunal, Thank you for your responses! The solution was even more simple. Replacing space symbol with \t in let formattedString = "\u{2022} \(string)\n gives you valid indentation.
For completeness, the full solution code is (just replacing one char):
func add(bulletList strings: [String],
font: UIFont,
indentation: CGFloat = 15,
lineSpacing: CGFloat = 3,
paragraphSpacing: CGFloat = 10,
textColor: UIColor = .black,
bulletColor: UIColor = .red) -> NSAttributedString {
func createParagraphAttirbute() -> NSParagraphStyle {
var paragraphStyle: NSMutableParagraphStyle
let nonOptions = NSDictionary() as! [NSTextTab.OptionKey: Any]
paragraphStyle = NSParagraphStyle.default.mutableCopy() as! NSMutableParagraphStyle
paragraphStyle.tabStops = [
NSTextTab(textAlignment: .left, location: indentation, options: nonOptions)]
paragraphStyle.defaultTabInterval = indentation
paragraphStyle.firstLineHeadIndent = 0
paragraphStyle.lineSpacing = lineSpacing
paragraphStyle.paragraphSpacing = paragraphSpacing
paragraphStyle.headIndent = indentation
return paragraphStyle
}
let bulletPoint = "\u{2022}"
let textAttributes: [NSAttributedStringKey: Any] = [.font: font, .foregroundColor: textColor]
let bulletAttributes: [NSAttributedStringKey: Any] = [.font: font, .foregroundColor: bulletColor]
let buffer = NSMutableAttributedString.init()
for string in strings {
let formattedString = "\(bulletPoint)\t\(string)\n"
let attributedString = NSMutableAttributedString(string: formattedString)
let paragraphStyle = createParagraphAttirbute()
attributedString.addAttributes(
[NSAttributedStringKey.paragraphStyle : paragraphStyle],
range: NSMakeRange(0, attributedString.length))
attributedString.addAttributes(
textAttributes,
range: NSMakeRange(0, attributedString.length))
let string:NSString = NSString(string: formattedString)
let rangeForBullet:NSRange = string.range(of: bulletPoint)
attributedString.addAttributes(bulletAttributes, range: rangeForBullet)
buffer.append(attributedString)
}
return buffer
}
Here's a simple Objective-C snippet, based mostly on the accepted answer:
NSString* text =
"•\tSome text for bullet 1.\n"
"•\tSome text for bullet 2.\n"
"•\tSome text for bullet 3."
UIFont* bodyFont = [UIFont preferredFontForTextStyle: UIFontTextStyleBody];
CGFloat indentSize = bodyFont.pointSize;
NSMutableParagraphStyle *paraStyle = [[[NSMutableParagraphStyle alloc] init];
paraStyle.alignment = NSTextAlignmentLeft;
paraStyle.tabStops = #[ [[[NSTextTab alloc] initWithTextAlignment:NSTextAlignmentLeft location:indentSize options:#{}] ];
paraStyle.defaultTabInterval = indentSize;
paraStyle.headIndent = indentSize;
paraStyle.firstLineHeadIndent = 0;
paraStyle.lineHeightMultiple = 0.85;
paraStyle.lineSpacing = 0;
paraStyle.paragraphSpacing = bodyFont.lineHeight * 0.25;
paraStyle.paragraphSpacingBefore = 0;
[atrStr appendAttributedString:
[[[NSMutableAttributedString alloc] initWithString:avc.message attributes:#{
NSParagraphStyleAttributeName:paraStyle,
NSFontAttributeName: bodyFont,
}]];
I'm looking to create a SpriteKit Node positioned at the location of a substring inside a UITextView. How would I retrieve the CGPoint location so I can position the SKNode there?
let textFont = [NSFontAttributeName: UIFont(name: "GillSansMT", size: 30.0) ?? UIFont.systemFontOfSize(18.0)]
attrString1 = NSMutableAttributedString(string: "My name is Dug.", attributes: textFont)
textShown1 = CustomTextView(frame: CGRectMake(CGRectGetMidX(self.frame), 175 + (90 * paragraphNumber), CGRectGetWidth(self.frame) - 80, CGRectGetHeight(self.frame)-400))
textShown1.attributedText = attrString1
self.view?.addSubview(textShown1)
You can use firstRectForRange(_:) method on UITextView
let textFont = [NSFontAttributeName: UIFont(name: "GillSansMT", size: 30.0) ?? UIFont.systemFontOfSize(18.0)]
let attrString1 = NSMutableAttributedString(string: "My name is Dug.", attributes: textFont)
// range of substring to search
let str1 = attrString1.string as NSString
let range = str1.rangeOfString("name", options: nil, range: NSMakeRange(0, str1.length))
// prepare the textview
let textView = UITextView(frame:CGRectMake(0,0,200,200))
textView.attributedText = attrString1
// you should ensure layout
textView.layoutManager.ensureLayoutForTextContainer(textView.textContainer)
// text position of the range.location
let start = textView.positionFromPosition(textView.beginningOfDocument, offset: range.location)!
// text position of the end of the range
let end = textView.positionFromPosition(start, offset: range.length)!
// text range of the range
let tRange = textView.textRangeFromPosition(start, toPosition: end)
// here it is!
let rect = textView.firstRectForRange(tRange)
I want to add both text and image in UITextView. The textview should be expanded according to the length of the text and image. In short what I want to do is that when I capture an image from camera or pick from gallery then it should display in UITextView and I should also be able to add some text with that image similar to Facebook.I am also attaching an image that how the UITextView will look like.
This is absolutely possible now, using
+ (NSAttributedString *)attributedStringWithAttachment:(NSTextAttachment *)attachment
See Apple docs here
And this example taken from this other answer:
UITextView *textView = [[UITextView alloc] initWithFrame:CGRectMake(0,0,140,140)];
NSMutableAttributedString *attributedString = [[NSMutableAttributedString alloc] initWithString:#"before after"];
NSTextAttachment *textAttachment = [[NSTextAttachment alloc] init];
textAttachment.image = [UIImage imageNamed:#"sample_image.jpg"];
CGFloat oldWidth = textAttachment.image.size.width;
//I'm subtracting 10px to make the image display nicely, accounting
//for the padding inside the textView
CGFloat scaleFactor = oldWidth / (textView.frame.size.width - 10);
textAttachment.image = [UIImage imageWithCGImage:textAttachment.image.CGImage scale:scaleFactor orientation:UIImageOrientationUp];
NSAttributedString *attrStringWithImage = [NSAttributedString attributedStringWithAttachment:textAttachment];
[attributedString replaceCharactersInRange:NSMakeRange(6, 1) withAttributedString:attrStringWithImage];
textView.attributedText = attributedString;
Using the above code will get you an image with text inside a UITextView on iOS 7+. You can/show style the attributed text as you want it and probably set the width of the image to make sure it fits within your textView (as well as setting your own aspect ratio/scale preference)
Here's a quick test image:
Thank you for your code, it actually worked. I make a code in the Swift, so I would like to share Swift version of your code. I checked this code works too.
let textView = UITextView(frame: CGRectMake(50, 50, 200, 300))
let attributedString = NSMutableAttributedString(string: "before after")
let textAttachment = NSTextAttachment()
textAttachment.image = UIImage(named: "sample_image.jpg")!
let oldWidth = textAttachment.image!.size.width;
//I'm subtracting 10px to make the image display nicely, accounting
//for the padding inside the textView
let scaleFactor = oldWidth / (textView.frame.size.width - 10);
textAttachment.image = UIImage(CGImage: textAttachment.image!.CGImage, scale: scaleFactor, orientation: .Up)
var attrStringWithImage = NSAttributedString(attachment: textAttachment)
attributedString.replaceCharactersInRange(NSMakeRange(6, 1), withAttributedString: attrStringWithImage)
textView.attributedText = attributedString;
self.view.addSubview(textView)
Code For Swift 3.0
var attributedString :NSMutableAttributedString!
attributedString = NSMutableAttributedString(attributedString:txtBody.attributedText)
let textAttachment = NSTextAttachment()
textAttachment.image = image
let oldWidth = textAttachment.image!.size.width;
//I'm subtracting 10px to make the image display nicely, accounting
//for the padding inside the textView
let scaleFactor = oldWidth / (txtBody.frame.size.width - 10);
textAttachment.image = UIImage(cgImage: textAttachment.image!.cgImage!, scale: scaleFactor, orientation: .up)
let attrStringWithImage = NSAttributedString(attachment: textAttachment)
attributedString.append(attrStringWithImage)
txtBody.attributedText = attributedString;
if you just want place the image in the end, you can use
//create your UIImage
let image = UIImage(named: change_arr[indexPath.row]);
//create and NSTextAttachment and add your image to it.
let attachment = NSTextAttachment()
attachment.image = image
//put your NSTextAttachment into and attributedString
let attString = NSAttributedString(attachment: attachment)
//add this attributed string to the current position.
textView.textStorage.insertAttributedString(attString, atIndex: textView.selectedRange.location)
Check This answer
if you want to get the image from the camera, you can try my code below: (Swift 3.0)
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [String : Any]) {
let image = info[UIImagePickerControllerOriginalImage] as! UIImage
//create and NSTextAttachment and add your image to it.
let attachment = NSTextAttachment()
attachment.image = image
//calculate new size. (-20 because I want to have a litle space on the right of picture)
let newImageWidth = (textView.bounds.size.width - 20 )
let scale = newImageWidth/image.size.width
let newImageHeight = image.size.height * scale
//resize this
attachment.bounds = CGRect.init(x: 0, y: 0, width: newImageWidth, height: newImageHeight)
//put your NSTextAttachment into and attributedString
let attString = NSAttributedString(attachment: attachment)
//add this attributed string to the current position.
textView.textStorage.insert(attString, at: textView.selectedRange.location)
picker.dismiss(animated: true, completion: nil)
}
Lots of people are making this a lot more complicated than it needs to be. Firstly, add your image to the catalogue at the right size:
Then, create the NSAttributedString in code:
// Creates the logo image
let twitterLogoImage = NSTextAttachment()
twitterLogoImage.image = UIImage(named: "TwitterLogo")
let twitterLogo = NSAttributedString(attachment: twitterLogoImage)
Then add the NSAttributedString to what you want:
// Create the mutable attributed string
let text = NSMutableAttributedString(string: "")
/* Code above, creating the logo */
/* More attributed strings */
// Add the logo to the whole text
text.append(twitterLogo)
textView.attributedText = text
Or:
textView.attributedText = twitterLogo
let htmlString = "<html><body><h1>This is the title</h1><p>This is the first paragraph.</p><img src=\"https://miro.medium.com/max/9216/1*QzxcfBpKn5oNM09-vxG_Tw.jpeg\" width=\"360\" height=\"240\"><p>This is the second paragraph.</p><p>This is the third paragraph.</p><p>This is the fourth paragraph.</p><p>This is the last paragraph.</p></body></html>"
Use this string extension:
extension String {
func convertToAttributedFromHTML() -> NSAttributedString? {
var attributedText: NSAttributedString?
let options: [NSAttributedString.DocumentReadingOptionKey: Any] = [.documentType: NSAttributedString.DocumentType.html, .characterEncoding: String.Encoding.utf8.rawValue]
if let data = data(using: .unicode, allowLossyConversion: true), let attrStr = try? NSAttributedString(data: data, options: options, documentAttributes: nil) {
attributedText = attrStr
}
return attributedText
}
}
Then set the textView:
textView.attributedText = htmlString.convertToAttributedFromHTML()
If you want to add a simple image in textview from Gallery or Camera as an attachment then this method should be used:
func insertImage(_ image:UIImage) {
let attachment = NSTextAttachment()
attachment.image = image
attachment.setImageHeight(height: 200)
let attString = NSAttributedString(attachment: attachment)
/// at is current cursor position
self.descriptionTextView.textStorage.insert(attString, at: self.descriptionTextView.selectedRange.location)
descriptionTextView.font = UIFont(name: UIFont.avenirNextRegular, size: 17)
descriptionTextView.textColor = .white
}
If you want to add an image from a link than you need to do this, there can be multiple links in string, so will be achive using these methods.
Checks URLS from exisiting string so that we can download an image and show as attachment
func checkForUrls(text: String) -> [NSTextCheckingResult] {
let types: NSTextCheckingResult.CheckingType = .link
do {
let detector = try NSDataDetector(types: types.rawValue)
let matches = detector.matches(in: text, options: .reportCompletion, range: NSRange(location: 0, length: text.count))
return matches
// return matches.compactMap({$0.url})
} catch let error {
debugPrint(error.localizedDescription)
}
return []
}
// Recursive funtion call next after successfull downloaded
func convertToAttachment() {
if imageURLResultsFromStr.count > 0 {
imageTOAttributedText(imageURLResultsFromStr.first?.url, imageURLResultsFromStr.first?.range)
}
}
**// MARK: Server URL to Image conversion to show**
func imageTOAttributedText(_ url:URL?,_ range:NSRange?) {
guard let url = url, let range = range else { return }
let imgView = UIImageView()
imgView.kf.setImage(with: url, completionHandler: { result in
switch result {
case .success(var data):
let attachment = NSTextAttachment()
data.image.accessibilityIdentifier = self.recentlyUploadedImage
attachment.image = data.image
// attachment.fileType = self.recentlyUploadedImage
attachment.setImageHeight(height: 200)
/// This will help to remove existing url from server which we have sent as url
/// Start
let mutStr = self.descriptionTextView.attributedText.mutableCopy() as! NSMutableAttributedString
let range = (mutStr.string as NSString).range(of: "\n\(url.absoluteString)\n")
mutStr.deleteCharacters(in: range)
self.descriptionTextView.attributedText = mutStr
//End
/// Add image as attachment downloaded from url
let attString = NSAttributedString(attachment: attachment)
self.descriptionTextView.textStorage.insert(attString, at: range.location)
/// Recursivly calls to check how many urls we have in string to avoid wrong location insertion
/// We need to re-calculate new string from server after removing url string and add image as attachment
self.imageURLResultsFromStr.remove(at: 0)
self.imageURLResultsFromStr = self.checkForUrls(text: self.descriptionTextView.text)
self.convertToAttachment()
case .failure(let error):
print(error)
}
})
}
// Now call this function and initialise it from server string, Call it from viewdidload or from api response
func initialise() {
self.descriptionTextView.text = ..string from server
self.imageURLResultsFromStr = self.checkForUrls(text:string from server)
convertToAttachment()
}
var imageURLResultsFromStr:[NSTextCheckingResult] = []
NSURL *aURL = [NSURL URLWithString:[[NSString stringWithFormat:#"%#%#",Image_BASE_URL,str] stringByAddingPercentEscapesUsingEncoding: NSUTF8StringEncoding]];
//UIImage *aImage = [UIImage imageWithData:[NSData dataWithContentsOfURL:aURL]];
//[aImage drawInRect:CGRectMake(0, 0, 20, 20)];
__block NSTextAttachment *imageAttachment = [NSTextAttachment new];
imageAttachment.bounds = CGRectMake(0, -5, 20, 20);
NSAttributedString *stringWithImage = [NSAttributedString attributedStringWithAttachment:imageAttachment];
[deCodedString replaceCharactersInRange:NSMakeRange(deCodedString.length, 0) withAttributedString:stringWithImage];
incomingMessage.messageAttributedString = deCodedString;
SDWebImageDownloader *downloader = [SDWebImageDownloader sharedDownloader];
imageAttachment.image = [UIImage imageNamed:#"profile_main_placeholder"];
[downloader downloadImageWithURL:aURL
options:0
progress:^(NSInteger receivedSize, NSInteger expectedSize) {
// progression tracking code
}
completed:^(UIImage *image, NSData *data, NSError *error, BOOL finished) {
if (image && finished) {
[image drawInRect:CGRectMake(0, 0, 20, 20)];
imageAttachment.image = image;
dispatch_async(dispatch_get_main_queue(), ^(void)
{
[self.tbl_Conversation reloadRowsAtIndexPaths:[self.tbl_Conversation indexPathsForVisibleRows]
withRowAnimation:UITableViewRowAnimationNone];
[self.tbl_Conversation reloadData];
});
// NSAttributedString *stringWithImage = [NSAttributedString attributedStringWithAttachment:imageAttachment];
// [deCodedString replaceCharactersInRange:NSMakeRange(deCodedString.length, 0) withAttributedString:stringWithImage];
// incomingMessage.messageAttributedString = deCodedString;
}
}];
Please, try use placeholderTextView to simple input with icon placeholder support.
#IBOutlet weak var tvMessage: PlaceholderTextView!
let icon: NSTextAttachment = NSTextAttachment()
icon.image = UIImage(named: "paper-plane")
let iconString = NSMutableAttributedString(attributedString: NSAttributedString(attachment: icon))
tvMessage.icon = icon
let textColor = UIColor.gray
let lightFont = UIFont(name: "Helvetica-Light", size: tvMessage.font!.pointSize)
let italicFont = UIFont(name: "Helvetica-LightOblique", size: tvMessage.font!.pointSize)
let message = NSAttributedString(string: " " + "Personal Message", attributes: [ NSFontAttributeName: lightFont!, NSForegroundColorAttributeName: textColor])
iconString.append(message)
let option = NSAttributedString(string: " " + "Optional", attributes: [ NSFontAttributeName: italicFont!, NSForegroundColorAttributeName: textColor])
iconString.append(option)
tvMessage.attributedPlaceHolder = iconString
tvMessage.layoutSubviews()
You can refer to how MLLabel work.
Main ideal is NSTextAttachment
Create ImageAttachment extends NSTextAttachment -> override - (nullable UIImage *)imageForBounds:(CGRect)imageBounds textContainer:(nullable NSTextContainer *)textContainer characterIndex:(NSUInteger)charIndex to return image size like you want.
Create NSAttributedString with [NSAttributedString attributedStringWithAttachment:ImageAttachment]
Create NSMutableAttributedString and append attributed string of ImageAttachment using - (void)replaceCharactersInRange:(NSRange)range withAttributedString:(NSAttributedString *)attrString;
Result: You have NSMutableAttributedString contain your image and set it to textView.attributedText
Sample: HERE
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)?
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;
}
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.
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.
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
}
}
ResponsiveLabel is a subclass of UILabel which allows to add custom truncation token which responds to touch.
#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
}
}