Locate character coordinate in Custom UILabel with attributedText - ios

i need to locate my character location in my UILabel (it has ParagraphLineSpacing and AttributedText with multiline),
i have got my character's index, but now i can't get X and Y coordinate from my index.
i Have found this http://techqa.info/programming/question/19417776/how-do-i-locate-the-cgrect-for-a-substring-of-text-in-a-uilabel
and i translated to my Swift 3.1 code
func boundingRect(forCharacterRange range: NSRange) -> CGRect {
let textStorage = NSTextStorage(attributedString: self.attributedText!)
let layoutManager = NSLayoutManager()
textStorage.addLayoutManager(layoutManager)
let textContainer = NSTextContainer(size: bounds.size)
textContainer.lineFragmentPadding = 0
layoutManager.addTextContainer(textContainer)
var glyphRange: NSRange
// Convert the range for glyphs.
layoutManager.characterRange(forGlyphRange: range, actualGlyphRange: glyphRange)
return layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer)
}
but, unfortunately, i can't really use this code because actualGlyphRange ask NSRangePointer, not NSRange, so i changed my translated code to
func boundingRect(forCharacterRange range: NSRange) -> CGRect {
let textStorage = NSTextStorage(attributedString: self.attributedText!)
let layoutManager = NSLayoutManager()
textStorage.addLayoutManager(layoutManager)
let textContainer = NSTextContainer(size: bounds.size)
textContainer.lineFragmentPadding = 0
layoutManager.addTextContainer(textContainer)
//var glyphRange: NSRange
let a = MemoryLayout<NSRange>.size
let pointer:NSRangePointer = NSRangePointer.allocate(capacity: a)
layoutManager.characterRange(forGlyphRange: range, actualGlyphRange: pointer)
return layoutManager.boundingRect(forGlyphRange: range, in: textContainer)
}
i don't understand what
var glyphRange: NSrange
usage, so i removed it and now the code is working, but the result is 60% not accurate especially when my character located on the second line or the third line. Do i messed up the translation here? Or are there any better method to get my character coordinate accurately?
i use
NSMakeRange(index, 1)
for my params to locate one specific character
=======UPDATED=======
I have tried custom UITextView to access its layout Manager, but unfortunately, the position is still inaccurate if there are 2 lines or more. (only accurate if there is only 1 line in my textView)
class LyricTextView: UITextView {
func boundingRect(forCharacterRange range: NSRange) -> CGRect {
let inset = self.textContainerInset
let rect = self.layoutManager.boundingRect(forGlyphRange: range, in: textContainer).offsetBy(dx: inset.left, dy: inset.top)
return rect
}
}
Am i missing something in this new code? It is getting nearly done

An easier fix for your compilation error would be to use this:
var glyphRange = NSRange()
layoutManager.characterRange(forGlyphRange: range, actualGlyphRange: &glyphRange)
However, when I tried it, I also could only get correct rectangles for text on the first line.
If using a UITextView is ok for you, you have access to its layout manager and text container:
#IBOutlet var textView: UITextView!
...
let rect = textView!.layoutManager.boundingRect(
forGlyphRange: glyphRange, in: textView!.textContainer)
It seems you also need to take into account the text view's text container inset so the following worked for me to get a bounding rect for text on the second line:
let inset = textView!.textContainerInset
let rect = textView!.layoutManager.boundingRect(
forGlyphRange: glyphRange, in: textView!.textContainer)
.offsetBy(dx: inset.left, dy: inset.top)
I'd be interested if somebody finds a solution that works for UILabel.

Related

How to get height of string with maximum lines using NSTextStorage?

I am trying to get the height of an attributed string (for any font, any language, any strange utf8 characters, etc).
I found this interesting topic at Badoo Chatto about different solutions: https://github.com/badoo/Chatto/issues/129
And the solution I'm using is theirs:
func height(width: CGFloat, attributes: [NSAttributedString.Key: Any]) -> CGFloat {
let textContainer: NSTextContainer = {
let container = NSTextContainer(size: CGSize(width: width, height: .greatestFiniteMagnitude))
container.lineFragmentPadding = 0
return container
}()
let textStorage = NSTextStorage(string: self, attributes: attributes)
let layoutManager: NSLayoutManager = {
let layoutManager = NSLayoutManager()
layoutManager.addTextContainer(textContainer)
textStorage.addLayoutManager(layoutManager)
return layoutManager
}()
let rect = layoutManager.usedRect(for: textContainer)
return rect.size.round().height
}
How can I modify this logic so that it can take into consideration a maximum line number?
I tried adding container.maximumNumberOfLines = 2 but it won't change anything as NSTextContainer is set with an infinite height.
Ideally I would like to avoid using any UIView or subview as this processing has to be done in a background thread. Also, it appears that any UIKit-based solution isn't 100% reliable (cf the Badoo Chatto link).

iOS: Draw border around text UILabel

I want to draw an outline around the text of UILabel like this:
I tried using attributed text but there is no attribute for rendering border around text. Only underline is available. I also tried rendering html using attributed text but that didn't help either:
let htmlLabelText = String(format: "<html><body><span style='color:blue; border: 1.5px solid #55DF49; border-radius: 50px;'>%#</span></body></html>", labelText)
var attributedString = NSMutableAttributedString()
guard let stringData = data(using: .unicode) else { return NSMutableAttributedString() }
do {
attributedString = try NSMutableAttributedString(
data: stringData,
options: [NSDocumentTypeDocumentAttribute: NSHTMLTextDocumentType],
documentAttributes: nil
)
} catch {}
I checked other post but none of them helped.
The problem is quite hard to solve for UILabel, because you have no direct access to NSLayoutManager, which is key for my solution.
I have created IBDesignable UILabel subclass LineHighlightedLabel, which can do the job. The visual is not quite the same as image you provided but you can get there.
Important part is to set text to UILabel as NSAttributedString, not just plain text.
#IBDesignable
public class LineHighlightedLabel: UILabel {
public override func draw(_ rect: CGRect) {
let layoutManager = NSLayoutManager()
let textStorage = NSTextStorage.init(attributedString: attributedText!)
textStorage.addLayoutManager(layoutManager)
let textContainer = NSTextContainer.init(size: bounds.size)
textContainer.lineFragmentPadding = 0
textContainer.maximumNumberOfLines = numberOfLines
textContainer.lineBreakMode = lineBreakMode
layoutManager.addTextContainer(textContainer)
layoutManager.enumerateLineFragments(forGlyphRange: NSMakeRange(0, textStorage.length)) { (rect, usedRect, textContainer, glyphRange, bool) in
let lineRect = CGRect(x: usedRect.origin.x, y: usedRect.origin.y + 1, width: usedRect.size.width, height: usedRect.size.height - 2)
UIColor.green.setStroke()
let lineRectPath = UIBezierPath(roundedRect: lineRect, cornerRadius: 5)
lineRectPath.lineWidth = 0.5
lineRectPath.stroke()
}
super.drawText(in: rect)
}
public override func layoutSubviews() {
super.layoutSubviews()
setNeedsDisplay()
}
}
LineHighlightedLabel is providing nice preview in interface builder, you can play with values easily.
#shota's answers also works.
Here is exactly how I solved the issue by subclassing UILabel without using NSLayoutManager. I copied the code for getting lines from text from somewhere in stackoverflow, although using LayoutManager.enumerateLineFragments is a better choice. Also there is a hack required to render text of UILabel with two spaces at start and end. Here is a screenshot of end result:
Here is the answer.
label1.layer.borderColor = [UIColor greenColor].CGColor;
label1.layer.borderWidth =1.0
OR
label2.layer.borderColor = [UIColor blueColor].CGColor;
label1.layer.borderWidth =5.0
OR
label3.layer.borderWidth = 2.0;
label3.layer.cornerRadius = 8;
try these different possibilities.

NSLayoutManager, boundingRectForGlyphRange and emoji

I am displaying text that may contain emoji using NSAttributedString's drawInRect(rect: CGRect) method. Since I want to detect taps on the text I use the following method to see which character has been tapped:
let textStorage = NSTextStorage(attributedString: attributedString)
let layoutManager = NSLayoutManager()
layoutManager.usesFontLeading = true
textStorage.addLayoutManager(layoutManager)
let textContainer = NSTextContainer(size: containerSize)
layoutManager.addTextContainer(textContainer)
textContainer.lineFragmentPadding = 0.0
layoutManager.ensureLayoutForTextContainer(textContainer)
let tappedIndex = layoutManager.characterIndexForPoint(point,
inTextContainer: textContainer,
fractionOfDistanceBetweenInsertionPoints: nil)
This gives the correct index that I can work with until I start adding emoji to the text. As soon as emoji are added there starts to be an offset for the detection. This led me to look at the bounding rectangles of glyphs that I was looking for. I noticed that the bounding rectangles of emoji were too large. I set up the following test case to check the difference:
let emojiText = "😀"
let font = UIFont.systemFontOfSize(20.0)
let containerSize = CGSize(width: 300.0, height: 20000.0)
let attributedString = NSAttributedString(string: emojiText, attributes: [NSFontAttributeName: font])
let textStorage = NSTextStorage(attributedString: attributedString)
let layoutManager = NSLayoutManager()
layoutManager.usesFontLeading = true
textStorage.addLayoutManager(layoutManager)
let textContainer = NSTextContainer(size: containerSize)
layoutManager.addTextContainer(textContainer)
textContainer.lineFragmentPadding = 0.0
layoutManager.ensureLayoutForTextContainer(textContainer)
let glyphRect = layoutManager.boundingRectForGlyphRange(NSRange(location: 0, length: attributedString.length), inTextContainer: textContainer)
let boundingRect = attributedString.boundingRectWithSize(containerSize, options:[.UsesLineFragmentOrigin, .UsesFontLeading], context: nil)
Executing this code resulted the following CGRects:
glyphRect = (0.0, 0.0, 23.0, 28.875)
boundingRect = (0.0, 0.0, 23.0, 23.8671875)
What this means is that these two methods give two entirely different sizes! This wouldn't be a problem, but the 'offset' stacks with more lines.
Example of the stacked offset
I set a purple background for the character the characterIndexForPoint gave me, gave the rect of boundingRectForGlyphRange a green outline and the yellow dot is the actual taplocation. Note that the green rectangle lines up nicely with a different character, however, this is no indication whatsoever, since it just happens to line up nicely in this specific case.
Am I overlooking something obvious or is this an issue in iOS?
I have solved the issue. It appears that NSAttributedString.drawInRect draws differently from CoreText. I now use the following code to draw the text in drawRect:
let totalRange = layoutManager.glyphRangeForTextContainer(textContainer)
layoutManager.drawBackgroundForGlyphRange(range, atPoint: CGPointZero)
layoutManager.drawGlyphsForGlyphRange(range, atPoint: CGPointZero)

boundingRectForGlyphRange always returns rect for font size 12

I am trying to calculate the rects of individual glyphs using boundingRectForGlyphRange method. Everything works well except it always returns the rect as if the font size was 12. Event though I set it to 20 in attributed string.
let font = UIFont.systemFontOfSize(20)
var attributedString = NSMutableAttributedString(string: text)
let range = NSRange(location: 0, length: countElements(text))
attributedString.addAttribute(NSFontAttributeName, value: font, range: range)
let textContainer = NSTextContainer()
textContainer.size = CGSize(width: 300, height: 100)
let layoutManager = NSLayoutManager()
let textStorage = NSTextStorage(string: attributedString.mutableString)
layoutManager.textStorage = textStorage
layoutManager.addTextContainer(textContainer)
let rect = layoutManager.boundingRectForGlyphRange(NSRange(location: 0, length: 1), inTextContainer: textContainer)
How do I make it return rect for text size 20 instead of 12?
Just initialise NSTextStorage with attributedString:.
let textStorage = NSTextStorage(attributedString: attributedString)
instead of
let textStorage = NSTextStorage(string: attributedString.mutableString)

How do I get word wrap information with the new iOS 7 APIs?

I noticed that iOS 7 introduces new classes related to text layout such as NSLayoutManager, NSTextStorage, and NSTextContainer. How can I use these in order to get information about word wrapping on an NSString?
For example, say I have a long NSString which I put in a UILabel. If I enable multiple lines on the UILabel, it would produce a string such as the following:
The quick brown fox jumps
over the lazy dog.
That's great, but I can't access the line breaks in code (e.g. after the word jumps I would want it to return \n or something similar). I would want to know at which character indexes the line breaks occur. I know we can do this with CoreText, but since we have these new classes in iOS 7, I was wondering how we can use them instead.
Example:
CGFloat maxWidth = 150;
NSAttributedString *s =
[[NSAttributedString alloc]
initWithString:#"The quick brown fox jumped over the lazy dog."
attributes:#{NSFontAttributeName:[UIFont fontWithName:#"GillSans" size:20]}];
NSTextContainer* tc =
[[NSTextContainer alloc] initWithSize:CGSizeMake(maxWidth,CGFLOAT_MAX)];
NSLayoutManager* lm = [NSLayoutManager new];
NSTextStorage* tm = [[NSTextStorage alloc] initWithAttributedString:s];
[tm addLayoutManager:lm];
[lm addTextContainer:tc];
[lm enumerateLineFragmentsForGlyphRange:NSMakeRange(0,lm.numberOfGlyphs)
usingBlock:^(CGRect rect, CGRect usedRect,
NSTextContainer *textContainer,
NSRange glyphRange, BOOL *stop) {
NSRange r = [lm characterRangeForGlyphRange:glyphRange actualGlyphRange:nil];
NSLog(#"%#", [s.string substringWithRange:r]);
}];
swift translation:
guard let font1: UIFont = textView.font else { return }
var lines: [String] = []
let maxWidth: CGFloat = textView.frame.width
let s: NSAttributedString = NSAttributedString.init(string: textView.text, attributes: [.font: font1])
let tc: NSTextContainer = NSTextContainer.init(size: CGSize(width: maxWidth, height: CGFloat.greatestFiniteMagnitude))
let lm: NSLayoutManager = NSLayoutManager.init()
let tm: NSTextStorage = NSTextStorage.init(attributedString: s)
tm.addLayoutManager(lm)
lm.addTextContainer(tc)
lm.enumerateLineFragments(forGlyphRange: NSRange(location: 0, length: lm.numberOfGlyphs)) { (rect: CGRect, usedRect: CGRect, textContainer: NSTextContainer, glyphRange: NSRange, Bool) in
let r: NSRange = lm.characterRange(forGlyphRange: glyphRange, actualGlyphRange: nil)
let str = s as NSAttributedString
let s2 = str.attributedSubstring(from: r)
print(s2)
lines.append(s2.string)
}
I created an extesion of UITextView with the answer of #brontea, it returns the text of the textView formatted with line breaks to save the string the same way the user sees it:
extension UITextView {
func textFormattedWithLineBreaks() -> String? {
guard let font: UIFont = self.font else { return "" }
var textPerLine: [String] = []
let maxWidth: CGFloat = self.frame.width
let attributedString: NSAttributedString = NSAttributedString.init(string: self.text, attributes: [.font: font])
let textContainer: NSTextContainer = NSTextContainer.init(size: CGSize(width: maxWidth, height: CGFloat.greatestFiniteMagnitude))
let layoutManager: NSLayoutManager = NSLayoutManager.init()
let textStorage: NSTextStorage = NSTextStorage.init(attributedString: attributedString)
textStorage.addLayoutManager(layoutManager)
layoutManager.addTextContainer(textContainer)
layoutManager.enumerateLineFragments(forGlyphRange: NSRange(location: 0, length: layoutManager.numberOfGlyphs)) { (rect: CGRect, usedRect: CGRect, textContainer: NSTextContainer, glyphRange: NSRange, Bool) in
let range: NSRange = layoutManager.characterRange(forGlyphRange: glyphRange, actualGlyphRange: nil)
let string = attributedString as NSAttributedString
let stringWithRange = string.attributedSubstring(from: range)
print(stringWithRange.string)
textPerLine.append(stringWithRange.string)
}
return textPerLine.map{ "\($0)\n" }.joined()
}
}

Resources