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
}
}
Related
How do I split a long string into three different strings every time it goes to the end of the screen and starts a new line.
Note: the string has no newline characters.
So basically as an example, I want to have 3 different strings from a single string because the string is long and goes to the end of the screen twice. Look at the image below.
The image below is what I currently have, it is one long string and I want to break it down into smaller 3 strings every time it wraps.
The code below gives you the number of times the long string wraps around the screen.
extension UILabel {
func numberOfLines() -> Int {
let maxSize = CGSize(width: frame.size.width, height: CGFloat(Float.infinity))
let charSize = font.lineHeight
let text = (self.text ?? "") as NSString
let textSize = text.boundingRect(with: maxSize, options: .usesLineFragmentOrigin, attributes: [NSAttributedString.Key.font: font], context: nil)
let linesRoundedUp = Int(ceil(textSize.height/charSize))
return linesRoundedUp
}
}
If I understood you correctly you want to wrap the long text.
You can do something like this to achieve the above:
label.numberOfLines = 0;
label.lineBreakMode = .byWordWrapping
and if you want the label to adjust it font size to the available width you can do something like this:
label.numberOfLines = 0;
label.lineBreakMode = .byClipping
label.adjustsFontSizeToFitWidth = true
I don't know if I understood you correctly, but you need to separate every line in label into separate string. I separate all the words by whitespace and recount numberOflines on each loop. Here is my code, sorry its messy, but I hope you will understand algorithm
extension UILabel {
func splitToLines() -> [String] {
var array: [String] = []
var words = text!.components(separatedBy: " ")
var lines = numberOfLines(in: text)
var currentLine = ""
while lines != 0 {
guard
let firstWord = words.first
else {
lines = 0
break
}
currentLine = currentLine.count == 0 ? firstWord : currentLine + " " + firstWord
words.removeFirst()
let currentNumber = numberOfLines(in: words.joined(separator: " "))
if lines != currentNumber {
lines = currentNumber
currentLine = currentLine + " " + words.first!
words.removeFirst()
array.append(currentLine)
currentLine = ""
}
}
array.append(words.joined(separator: " "))
return array
}
func numberOfLines(in string: String?) -> Int {
let maxSize = CGSize(width: frame.size.width, height: CGFloat(Float.infinity))
let charSize = font.lineHeight
let text = (string ?? "") as NSString
let textSize = text.boundingRect(with: maxSize, options: .usesLineFragmentOrigin, attributes: [NSAttributedString.Key.font: font!], context: nil)
let linesRoundedUp = Int(ceil(textSize.height / charSize))
return linesRoundedUp
}
}
Is there a way to set lineSpacing property of NSParagraphStyle / NSMutableParagraphStyle attribute of an NSAttributedString object to a percentage of the font's base/original lineSpacing?
lineSpacing is a property of NSParagraphStyle rather than UIFont so it's not possible to know what the original lineSpacing for a font is.
As a reference, here's a little extension I wrote for assigning lineSpacing to a UILabel (accumulative, does not cause information loss on the label's attributedString):
import UIKit
extension UILabel {
/// -important: must be called after setting the `text` property so the computed NSRange would be valid
func setLineSpacing(with lineSpacing: CGFloat) {
// get safe string
guard let textString = self.text, !textString.isEmpty
else { return }
// get the range
let entireRange: NSRange = (textString as NSString).range(of: textString)
guard entireRange.isValidNSRange(within: textString)
else { assertionFailure() ; return }
// NSMutableAttributedText
guard let mutableAttributedText: NSMutableAttributedString = self.attributedText?.mutableCopy() as? NSMutableAttributedString
else { assertionFailure() ; return }
// NSParagraphStyle
var paragraphStyle: NSParagraphStyle? = mutableAttributedText.attribute(NSParagraphStyleAttributeName, at: 0, longestEffectiveRange: nil, in: entireRange) as? NSParagraphStyle
if paragraphStyle == nil {
// why TTTAttributedLabel :(
paragraphStyle = NSParagraphStyle()
}
// safe NSParagraphStyle
guard let safeParagraphStyle: NSParagraphStyle = paragraphStyle
else { assertionFailure() ; return }
// NSMutableParagraphStyle
guard let mutableParagraphStyle: NSMutableParagraphStyle = safeParagraphStyle.mutableCopy() as? NSMutableParagraphStyle
else { assertionFailure() ; return }
// this is where the magic happens
mutableParagraphStyle.lineSpacing = lineSpacing
mutableAttributedText.addAttribute(NSParagraphStyleAttributeName, value: mutableParagraphStyle, range: entireRange)
// assign attributed text which has all the existing attributes plus the new line spacing attribute which is inside the paragraphstyle attribute...
self.attributedText = mutableAttributedText
}
}
extension NSRange {
// Is it safe to use range on the string?
func isValidNSRange(within string: String) -> Bool {
if self.location != NSNotFound
&& self.length > 0
&& self.location + self.length - 1 < string.length
&& self.location >= 0
{
return true
} else {
return false
}
}
}
I already researched the problem, found no answer on Stackoverflow. So I'm sharing my own FWIW. (I wanted to include CoreText tag so this would be easily searchable, but my reputation does not allow me)
Solution
extension UIFont {
func getLineSpacing(with percentage: CGFloat = 1) -> CGFloat {
let lineSpacing: CGFloat = (self.lineHeight - self.pointSize)// /2
return lineSpacing * percentage
}
}
I based my solution this article and this interesting Chinese article. More specifically, I'm attaching screenshots of the parts I used to infer the answer:
I have a text view with a string:
#"The best football player in the world is #OPTION, and the best basketball player is #OPTION?"
This is an example what I have to do. I want to replace #OPTION with a dropdown list, which means with custom view. It depends on the question, it can be only one #OPTION or more. Thanks in advance.
Edited:
The string is from API and it appears in UITextView or UILabel
After understanding your needs, The main issue you are facing is not knowing where to add the select lists.
I have created 2 categories for your case, for UILabel and for UITextView,
following these posts which contain the relevant answers for that:
How do I locate the CGRect for a substring of text in a UILabel?
Get X and Y coordinates of a word in UITextView
These categories find the CGRect for a string inside, which is where you should position your pickers.
The down-part of this for UILabel, is that it doesn't handle wordWrap line breaking mode well, and therefore it won't find the correct location, to handle this correctly, you should add line breaks when needed in case you use the UILabel.
UILabel:
extension UILabel
{
func rectFor(string str : String, fromIndex: Int = 0) -> (CGRect, NSRange)?
{
// Find the range of the string
guard self.text != nil else { return nil }
let subStringToSearch : NSString = (self.text! as NSString).substring(from: fromIndex) as NSString
var stringRange = subStringToSearch.range(of: str)
if (stringRange.location != NSNotFound)
{
guard self.attributedText != nil else { return nil }
// Add the starting point to the sub string
stringRange.location += fromIndex
let storage = NSTextStorage(attributedString: self.attributedText!)
let layoutManager = NSLayoutManager()
storage.addLayoutManager(layoutManager)
let textContainer = NSTextContainer(size: self.frame.size)
textContainer.lineFragmentPadding = 0
textContainer.lineBreakMode = .byWordWrapping
layoutManager.addTextContainer(textContainer)
var glyphRange = NSRange()
layoutManager.characterRange(forGlyphRange: stringRange, actualGlyphRange: &glyphRange)
let resultRect = layoutManager.boundingRect(forGlyphRange: glyphRange, in:textContainer)
return (resultRect, stringRange)
}
return nil
}
}
Usage for infinite searching all available substring (I recommend adding it in viewDidLayoutSubviews() in case you use auto-layout:
var lastFoundIndex : Int = 0
while let result = self.label.rectFor(string: "#OPTION", fromIndex: lastFoundIndex)
{
let view : UIView = UIView(frame: result.0)
view.backgroundColor = UIColor.red
self.label.addSubview(view)
lastFoundIndex = result.1.location + 1
}
And the same one for UITextView:
extension UITextView
{
func rectFor(string str : String, fromIndex: Int = 0) -> (CGRect, NSRange)?
{
// Find the range of the string
guard self.text != nil else { return nil }
let subStringToSearch : NSString = (self.text! as NSString).substring(from: fromIndex) as NSString
var stringRange = subStringToSearch.range(of: str)
if (stringRange.location != NSNotFound)
{
guard self.attributedText != nil else { return nil }
// Add the starting point to the sub string
stringRange.location += fromIndex
// Find first position
let startPosition = self.position(from: self.beginningOfDocument, offset: stringRange.location)
let endPosition = self.position(from: startPosition!, offset: stringRange.length)
let resultRange = self.textRange(from: startPosition!, to: endPosition!)
let resultRect = self.firstRect(for: resultRange!)
return (resultRect, stringRange)
}
return nil
}
}
Usage:
var lastFoundTextIndex : Int = 0
while let result = self.textView.rectFor(string: "#OPTION", fromIndex: lastFoundTextIndex)
{
let view : UIView = UIView(frame: result.0)
view.backgroundColor = UIColor.red
self.textView.addSubview(view)
lastFoundTextIndex = result.1.location + 1
}
In your case, textview gives the best results and uses included methods for that, the sample code uses a label & a text view, initialized in the code:
self.label.text = "The best football player in the world is\n#OPTION, and the best basketball player\n is #OPTION?"
self.textView.text = "The best football player in the world is #OPTION, and the best basketball player is #OPTION?"
And the output just adds views on top of the "#OPTION" strings:
Hope this helps
EDIT - Added Objective-C Variation:
Create 2 extensions - 1 for UITextView and 1 for UILabel:
UILabel:
#interface UILabel (UILabel_SubStringRect)
- (NSDictionary*)rectForString:(NSString*)string fromIndex:(int)index;
#end
#import "UILabel+SubStringRect.h"
#implementation UILabel (UILabel_SubStringRect)
- (NSDictionary*)rectForString:(NSString*)string fromIndex:(int)index
{
if (string != nil)
{
NSString* subStringToSearch = [self.text substringFromIndex:index];
NSRange stringRange = [subStringToSearch rangeOfString:string];
if (stringRange.location != NSNotFound)
{
if (self.attributedText != nil)
{
stringRange.location += index;
NSTextStorage* storage = [[NSTextStorage alloc] initWithAttributedString:self.attributedText];
NSLayoutManager* layoutManager = [NSLayoutManager new];
[storage addLayoutManager:layoutManager];
NSTextContainer* textContainer = [[NSTextContainer alloc] initWithSize:self.frame.size];
textContainer.lineFragmentPadding = 0;
textContainer.lineBreakMode = NSLineBreakByWordWrapping;
[layoutManager addTextContainer:textContainer];
NSRange glyphRange;
[layoutManager characterRangeForGlyphRange:stringRange actualGlyphRange:&glyphRange];
CGRect resultRect = [layoutManager boundingRectForGlyphRange:glyphRange inTextContainer:textContainer];
return #{ #"rect" : [NSValue valueWithCGRect:resultRect], #"range" : [NSValue valueWithRange:stringRange] };
}
}
}
return nil;
}
#end
UITextView:
#interface UITextView (SubStringRect)
- (NSDictionary*)rectForString:(NSString*)string fromIndex:(int)index;
#end
#import "UITextView+SubStringRect.h"
#implementation UITextView (SubStringRect)
- (NSDictionary*)rectForString:(NSString*)string fromIndex:(int)index
{
if (string != nil)
{
NSString* subStringToSearch = [self.text substringFromIndex:index];
NSRange stringRange = [subStringToSearch rangeOfString:string];
if (stringRange.location != NSNotFound)
{
if (self.attributedText != nil)
{
stringRange.location += index;
UITextPosition* startPosition = [self positionFromPosition:self.beginningOfDocument offset:stringRange.location];
UITextPosition* endPosition = [self positionFromPosition:startPosition offset:stringRange.length];
UITextRange* resultRange = [self textRangeFromPosition:startPosition toPosition:endPosition];
CGRect resultRect = [self firstRectForRange:resultRange];
return #{ #"rect" : [NSValue valueWithCGRect:resultRect], #"range" : [NSValue valueWithRange:stringRange] };
}
}
}
return nil;
}
#end
Usage Sample - UILabel:
int lastFoundIndex = 0;
NSDictionary* resultDict = nil;
do
{
resultDict = [self.label rectForString:#"#OPTION" fromIndex:lastFoundIndex];
if (resultDict != nil)
{
NSLog(#"result: %#", resultDict[#"rect"]);
UIView* view = [[UIView alloc] initWithFrame:[resultDict[#"rect"] CGRectValue]];
[view setBackgroundColor:[UIColor redColor]];
[self.label addSubview:view];
lastFoundIndex = (int)[resultDict[#"range"] rangeValue].location + 1;
}
} while (resultDict != nil);
UITextView:
int lastFoundTextIndex = 0;
NSDictionary* resultTextDict = nil;
do
{
resultTextDict = [self.textview rectForString:#"#OPTION" fromIndex:lastFoundTextIndex];
if (resultTextDict != nil)
{
NSLog(#"result: %#", resultTextDict[#"rect"]);
UIView* view = [[UIView alloc] initWithFrame:[resultTextDict[#"rect"] CGRectValue]];
[view setBackgroundColor:[UIColor redColor]];
[self.textview addSubview:view];
lastFoundTextIndex = (int)[resultTextDict[#"range"] rangeValue].location + 1;
}
} while (resultTextDict != nil);
I have a UITextView which displays an NSAttributedString. This string contains words that I'd like to make tappable, such that when they are tapped I get called back so that I can perform an action. I realise that UITextView can detect taps on a URL and call back my delegate, but these aren't URLs.
It seems to me that with iOS 7 and the power of TextKit this should now be possible, however I can't find any examples and I'm not sure where to start.
I understand that it's now possible to create custom attributes in the string (although I haven't done this yet), and perhaps these will be useful to detecting if one of the magic words has been tapped? In any case, I still don't know how to intercept that tap and detect on which word the tap occurred.
Note that iOS 6 compatibility is not required.
I just wanted to help others a little more. Following on from Shmidt's response it's possible to do exactly as I had asked in my original question.
1) Create an attributed string with custom attributes applied to the clickable words. eg.
NSAttributedString* attributedString = [[NSAttributedString alloc] initWithString:#"a clickable word" attributes:#{ #"myCustomTag" : #(YES) }];
[paragraph appendAttributedString:attributedString];
2) Create a UITextView to display that string, and add a UITapGestureRecognizer to it. Then handle the tap:
- (void)textTapped:(UITapGestureRecognizer *)recognizer
{
UITextView *textView = (UITextView *)recognizer.view;
// Location of the tap in text-container coordinates
NSLayoutManager *layoutManager = textView.layoutManager;
CGPoint location = [recognizer locationInView:textView];
location.x -= textView.textContainerInset.left;
location.y -= textView.textContainerInset.top;
// Find the character that's been tapped on
NSUInteger characterIndex;
characterIndex = [layoutManager characterIndexForPoint:location
inTextContainer:textView.textContainer
fractionOfDistanceBetweenInsertionPoints:NULL];
if (characterIndex < textView.textStorage.length) {
NSRange range;
id value = [textView.attributedText attribute:#"myCustomTag" atIndex:characterIndex effectiveRange:&range];
// Handle as required...
NSLog(#"%#, %d, %d", value, range.location, range.length);
}
}
So easy when you know how!
Detecting taps on attributed text with Swift
Sometimes for beginners it is a little hard to know how to do get things set up (it was for me anyway), so this example is a little fuller.
Add a UITextView to your project.
Outlet
Connect the UITextView to the ViewController with an outlet named textView.
Custom attribute
We are going to make a custom attribute by making an Extension.
Note: This step is technically optional, but if you don't do it you will need to edit the code in the next part to use a standard attribute like NSAttributedString.Key.foregroundColor. The advantage of using a custom attribute is that you can define what values you want to store in the attributed text range.
Add a new swift file with File > New > File... > iOS > Source > Swift File. You can call it what you want. I am calling mine NSAttributedStringKey+CustomAttribute.swift.
Paste in the following code:
import Foundation
extension NSAttributedString.Key {
static let myAttributeName = NSAttributedString.Key(rawValue: "MyCustomAttribute")
}
Code
Replace the code in ViewController.swift with the following. Note the UIGestureRecognizerDelegate.
import UIKit
class ViewController: UIViewController, UIGestureRecognizerDelegate {
#IBOutlet weak var textView: UITextView!
override func viewDidLoad() {
super.viewDidLoad()
// Create an attributed string
let myString = NSMutableAttributedString(string: "Swift attributed text")
// Set an attribute on part of the string
let myRange = NSRange(location: 0, length: 5) // range of "Swift"
let myCustomAttribute = [ NSAttributedString.Key.myAttributeName: "some value"]
myString.addAttributes(myCustomAttribute, range: myRange)
textView.attributedText = myString
// Add tap gesture recognizer to Text View
let tap = UITapGestureRecognizer(target: self, action: #selector(myMethodToHandleTap(_:)))
tap.delegate = self
textView.addGestureRecognizer(tap)
}
#objc func myMethodToHandleTap(_ sender: UITapGestureRecognizer) {
let myTextView = sender.view as! UITextView
let layoutManager = myTextView.layoutManager
// location of tap in myTextView coordinates and taking the inset into account
var location = sender.location(in: myTextView)
location.x -= myTextView.textContainerInset.left;
location.y -= myTextView.textContainerInset.top;
// character index at tap location
let characterIndex = layoutManager.characterIndex(for: location, in: myTextView.textContainer, fractionOfDistanceBetweenInsertionPoints: nil)
// if index is valid then do something.
if characterIndex < myTextView.textStorage.length {
// print the character index
print("character index: \(characterIndex)")
// print the character at the index
let myRange = NSRange(location: characterIndex, length: 1)
let substring = (myTextView.attributedText.string as NSString).substring(with: myRange)
print("character at index: \(substring)")
// check if the tap location has a certain attribute
let attributeName = NSAttributedString.Key.myAttributeName
let attributeValue = myTextView.attributedText?.attribute(attributeName, at: characterIndex, effectiveRange: nil)
if let value = attributeValue {
print("You tapped on \(attributeName.rawValue) and the value is: \(value)")
}
}
}
}
Now if you tap on the "w" of "Swift", you should get the following result:
character index: 1
character at index: w
You tapped on MyCustomAttribute and the value is: some value
Notes
Here I used a custom attribute, but it could have just as easily been NSAttributedString.Key.foregroundColor (text color) that has a value of UIColor.green.
Formerly the text view could not be editable or selectable, but in my updated answer for Swift 4.2 it seems to be working fine no matter whether these are selected or not.
Further study
This answer was based on several other answers to this question. Besides these, see also
Advanced Text Layouts and Effects with Text Kit (WWDC 2013 video)
Attributed String Programming Guide
How do I make an attributed string using Swift?
This is a slightly modified version, building off of #tarmes answer. I couldn't get the valuevariable to return anything but null without the tweak below. Also, I needed the full attribute dictionary returned in order to determine the resulting action. I would have put this in the comments but don't appear to have the rep to do so. Apologies in advance if I have violated protocol.
Specific tweak is to use textView.textStorage instead of textView.attributedText. As a still learning iOS programmer, I am not really sure why this is, but perhaps someone else can enlighten us.
Specific modification in the tap handling method:
NSDictionary *attributesOfTappedText = [textView.textStorage attributesAtIndex:characterIndex effectiveRange:&range];
Full code in my view controller
- (void)viewDidLoad
{
[super viewDidLoad];
self.textView.attributedText = [self attributedTextViewString];
UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:#selector(textTapped:)];
[self.textView addGestureRecognizer:tap];
}
- (NSAttributedString *)attributedTextViewString
{
NSMutableAttributedString *paragraph = [[NSMutableAttributedString alloc] initWithString:#"This is a string with " attributes:#{NSForegroundColorAttributeName:[UIColor blueColor]}];
NSAttributedString* attributedString = [[NSAttributedString alloc] initWithString:#"a tappable string"
attributes:#{#"tappable":#(YES),
#"networkCallRequired": #(YES),
#"loadCatPicture": #(NO)}];
NSAttributedString* anotherAttributedString = [[NSAttributedString alloc] initWithString:#" and another tappable string"
attributes:#{#"tappable":#(YES),
#"networkCallRequired": #(NO),
#"loadCatPicture": #(YES)}];
[paragraph appendAttributedString:attributedString];
[paragraph appendAttributedString:anotherAttributedString];
return [paragraph copy];
}
- (void)textTapped:(UITapGestureRecognizer *)recognizer
{
UITextView *textView = (UITextView *)recognizer.view;
// Location of the tap in text-container coordinates
NSLayoutManager *layoutManager = textView.layoutManager;
CGPoint location = [recognizer locationInView:textView];
location.x -= textView.textContainerInset.left;
location.y -= textView.textContainerInset.top;
NSLog(#"location: %#", NSStringFromCGPoint(location));
// Find the character that's been tapped on
NSUInteger characterIndex;
characterIndex = [layoutManager characterIndexForPoint:location
inTextContainer:textView.textContainer
fractionOfDistanceBetweenInsertionPoints:NULL];
if (characterIndex < textView.textStorage.length) {
NSRange range;
NSDictionary *attributes = [textView.textStorage attributesAtIndex:characterIndex effectiveRange:&range];
NSLog(#"%#, %#", attributes, NSStringFromRange(range));
//Based on the attributes, do something
///if ([attributes objectForKey:...)] //make a network call, load a cat Pic, etc
}
}
Making custom link and doing what you want on the tap has become much easier with iOS 7.
There is very good example at Ray Wenderlich
WWDC 2013 example:
NSLayoutManager *layoutManager = textView.layoutManager;
CGPoint location = [touch locationInView:textView];
NSUInteger characterIndex;
characterIndex = [layoutManager characterIndexForPoint:location
inTextContainer:textView.textContainer
fractionOfDistanceBetweenInsertionPoints:NULL];
if (characterIndex < textView.textStorage.length) {
// valid index
// Find the word range here
// using -enumerateSubstringsInRange:options:usingBlock:
}
I was able to solve this pretty simply with NSLinkAttributeName
Swift 2
class MyClass: UIViewController, UITextViewDelegate {
#IBOutlet weak var tvBottom: UITextView!
override func viewDidLoad() {
super.viewDidLoad()
let attributedString = NSMutableAttributedString(string: "click me ok?")
attributedString.addAttribute(NSLinkAttributeName, value: "cs://moreinfo", range: NSMakeRange(0, 5))
tvBottom.attributedText = attributedString
tvBottom.delegate = self
}
func textView(textView: UITextView, shouldInteractWithURL URL: NSURL, inRange characterRange: NSRange) -> Bool {
UtilityFunctions.alert("clicked", message: "clicked")
return false
}
}
Complete example for detect actions on attributed text with Swift 3
let termsAndConditionsURL = TERMS_CONDITIONS_URL;
let privacyURL = PRIVACY_URL;
override func viewDidLoad() {
super.viewDidLoad()
self.txtView.delegate = self
let str = "By continuing, you accept the Terms of use and Privacy policy"
let attributedString = NSMutableAttributedString(string: str)
var foundRange = attributedString.mutableString.range(of: "Terms of use") //mention the parts of the attributed text you want to tap and get an custom action
attributedString.addAttribute(NSLinkAttributeName, value: termsAndConditionsURL, range: foundRange)
foundRange = attributedString.mutableString.range(of: "Privacy policy")
attributedString.addAttribute(NSLinkAttributeName, value: privacyURL, range: foundRange)
txtView.attributedText = attributedString
}
And then you can catch the action with shouldInteractWith URL UITextViewDelegate delegate method.So make sure you have set the delegate properly.
func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange) -> Bool {
let storyboard = UIStoryboard(name: "Main", bundle: nil)
let vc = storyboard.instantiateViewController(withIdentifier: "WebView") as! SKWebViewController
if (URL.absoluteString == termsAndConditionsURL) {
vc.strWebURL = TERMS_CONDITIONS_URL
self.navigationController?.pushViewController(vc, animated: true)
} else if (URL.absoluteString == privacyURL) {
vc.strWebURL = PRIVACY_URL
self.navigationController?.pushViewController(vc, animated: true)
}
return false
}
Like wise you can perform any action according to your requirement.
Cheers!!
It's possible to do that with characterIndexForPoint:inTextContainer:fractionOfDistanceBetweenInsertionPoints:. It'll work somewhat differently than you wanted - you'll have to test if a tapped character belongs to a magic word. But it shouldn't be complicated.
BTW I highly recommend watching Introducing Text Kit from WWDC 2013.
With Swift 5 and iOS 12, you can create a subclass of UITextView and override point(inside:with:) with some TextKit implementation in order to make only some NSAttributedStrings in it tappable.
The following code shows how to create a UITextView that only reacts to taps on underlined NSAttributedStrings in it:
InteractiveUnderlinedTextView.swift
import UIKit
class InteractiveUnderlinedTextView: UITextView {
override init(frame: CGRect, textContainer: NSTextContainer?) {
super.init(frame: frame, textContainer: textContainer)
configure()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
configure()
}
func configure() {
isScrollEnabled = false
isEditable = false
isSelectable = false
isUserInteractionEnabled = true
}
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
let superBool = super.point(inside: point, with: event)
let characterIndex = layoutManager.characterIndex(for: point, in: textContainer, fractionOfDistanceBetweenInsertionPoints: nil)
guard characterIndex < textStorage.length else { return false }
let attributes = textStorage.attributes(at: characterIndex, effectiveRange: nil)
return superBool && attributes[NSAttributedString.Key.underlineStyle] != nil
}
}
ViewController.swift
import UIKit
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
let linkTextView = InteractiveUnderlinedTextView()
linkTextView.backgroundColor = .orange
let mutableAttributedString = NSMutableAttributedString(string: "Some text\n\n")
let attributes = [NSAttributedString.Key.underlineStyle: NSUnderlineStyle.single.rawValue]
let underlinedAttributedString = NSAttributedString(string: "Some other text", attributes: attributes)
mutableAttributedString.append(underlinedAttributedString)
linkTextView.attributedText = mutableAttributedString
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(underlinedTextTapped))
linkTextView.addGestureRecognizer(tapGesture)
view.addSubview(linkTextView)
linkTextView.translatesAutoresizingMaskIntoConstraints = false
linkTextView.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
linkTextView.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true
linkTextView.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor).isActive = true
}
#objc func underlinedTextTapped(_ sender: UITapGestureRecognizer) {
print("Hello")
}
}
Use this extension for Swift:
import UIKit
extension UITapGestureRecognizer {
func didTapAttributedTextInTextView(textView: UITextView, inRange targetRange: NSRange) -> Bool {
let layoutManager = textView.layoutManager
let locationOfTouch = self.location(in: textView)
let index = layoutManager.characterIndex(for: locationOfTouch, in: textView.textContainer, fractionOfDistanceBetweenInsertionPoints: nil)
return NSLocationInRange(index, targetRange)
}
}
Add UITapGestureRecognizer to your text view with following selector:
guard let text = textView.attributedText?.string else {
return
}
let textToTap = "Tap me"
if let range = text.range(of: textToTap),
tapGesture.didTapAttributedTextInTextView(textView: textTextView, inRange: NSRange(range, in: text)) {
// Tap recognized
}
This one might work OK with short link, multilink in a textview. It work OK with iOS 6,7,8.
- (void)tappedTextView:(UITapGestureRecognizer *)tapGesture {
if (tapGesture.state != UIGestureRecognizerStateEnded) {
return;
}
UITextView *textView = (UITextView *)tapGesture.view;
CGPoint tapLocation = [tapGesture locationInView:textView];
NSDataDetector *detector = [NSDataDetector dataDetectorWithTypes:NSTextCheckingTypeLink|NSTextCheckingTypePhoneNumber
error:nil];
NSArray* resultString = [detector matchesInString:self.txtMessage.text options:NSMatchingReportProgress range:NSMakeRange(0, [self.txtMessage.text length])];
BOOL isContainLink = resultString.count > 0;
if (isContainLink) {
for (NSTextCheckingResult* result in resultString) {
CGRect linkPosition = [self frameOfTextRange:result.range inTextView:self.txtMessage];
if(CGRectContainsPoint(linkPosition, tapLocation) == 1){
if (result.resultType == NSTextCheckingTypePhoneNumber) {
NSString *phoneNumber = [#"telprompt://" stringByAppendingString:result.phoneNumber];
[[UIApplication sharedApplication] openURL:[NSURL URLWithString:phoneNumber]];
}
else if (result.resultType == NSTextCheckingTypeLink) {
[[UIApplication sharedApplication] openURL:result.URL];
}
}
}
}
}
- (CGRect)frameOfTextRange:(NSRange)range inTextView:(UITextView *)textView
{
UITextPosition *beginning = textView.beginningOfDocument;
UITextPosition *start = [textView positionFromPosition:beginning offset:range.location];
UITextPosition *end = [textView positionFromPosition:start offset:range.length];
UITextRange *textRange = [textView textRangeFromPosition:start toPosition:end];
CGRect firstRect = [textView firstRectForRange:textRange];
CGRect newRect = [textView convertRect:firstRect fromView:textView.textInputView];
return newRect;
}
This has changed as of iOS 10. In iOS 10, you can use the .link attribute and it all just works.
No need for custom attributes, tap gesture recognisers or anything. It works like an ordinary URL.
To do this, instead of adding the url to the NSMutableAttributedString, add what you want to call the url instead (eg, 'cats' to go to the wikipedia page about cats), and then add the standard attribute NSAttributedString.Key.link (I'm using Swift here), with the NSURL containing the target URL.
Reference: https://medium.com/real-solutions-artificial-intelligence/create-clickable-links-with-nsmutableattributedstring-12b6661a357d
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
}
}