NSLayoutManager, boundingRectForGlyphRange and emoji - ios

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)

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.

Vertically aligning NSTextAttachment in NSMutableAttributedString

I'm adding an icon to a UILabel using NSTextAttachment inside an NSMutableAttributedString like this:
//Setting up icon
let moneyIcon = NSTextAttachment()
moneyIcon.image = UIImage(named: "MoneyIcon")
let moneyIconString = NSAttributedString(attachment: moneyIcon)
//Setting up text
let balanceString = NSMutableAttributedString(string: " 1,702,200")
balanceString.insert(moneyIconString, at: 0)
//Adding string to label
self.attributedText = balanceString
self.sizeToFit()
But for some reason the icon isn't vertically aligned
Does anybody know how can I align it?
Thank you!
use bounds property of NSTextAttachment.
//Setting up icon
let moneyIcon = NSTextAttachment()
moneyIcon.image = UIImage(named: "MoneyIcon")
let imageSize = moneyIcon.image!.size
moneyIcon.bounds = CGRect(x: CGFloat(0), y: (font.capHeight - imageSize.height) / 2, width: imageSize.width, height: imageSize.height)
let moneyIconString = NSAttributedString(attachment: moneyIcon)
//Setting up text
let balanceString = NSMutableAttributedString(string: " 1,702,200")
balanceString.insert(moneyIconString, at: 0)
//Adding string to label
self.attributedText = balanceString
self.sizeToFit()
This answer, which is about vertically centering two differently sized fonts in a single NSAttributedString, mentions using the baseline offset to calculate the center of the string.
You can use the same approach when using an image:
Subtract the font size from the image's height and divide it by 2.
Subtract the font's descender from the value (since font size isn't the same as the ascent of your font). The font that you are particularly using (Baloo-Regular) has a descender value that differs from the standard and it should be divided by 2. Other fonts (including San Fransisco) don't need that fix or require a different divisor.
This code covers most cases, if your font behaves differently, you should check out the guide for managing texts in Text Kit.
// *Setting up icon*
let moneyIcon = NSTextAttachment()
// If you're sure a value is not and will never be nil, you can use "!".
// Otherwise, avoid it.
let moneyImage = UIImage(named: "MoneyIcon")!
moneyIcon.image = moneyImage
let moneyIconString = NSAttributedString(attachment: moneyIcon)
// *Setting up NSAttributedString attributes*
let balanceFontSize: CGFloat = 16
let balanceFont = UIFont(name: "Baloo", size: balanceFontSize)!
let balanceBaselineOffset: CGFloat = {
let dividend = moneyImage.size.height - balanceFontSize
return dividend / 2 - balanceFont.descender / 2
}()
let balanceAttr: [NSAttributedString.Key: Any] = [
.font: balanceFont,
.baselineOffset: balanceBaselineOffset
]
// *Setting up text*
let balanceString = NSMutableAttributedString(
string: " 1,702,200",
attributes: balanceAttr
)
balanceString.insert(moneyIconString, at: 0)

Locate character coordinate in Custom UILabel with attributedText

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.

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)

Resources