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

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

Related

UITextView does not adjust size when used in SwiftUI

My ultimate goal is to display html content in SwiftUI.
For that I am using UIKit's UITextView (I can't use web view, because I need to control font and text color).
This is the entire code of the view representable:
struct HTMLTextView: UIViewRepresentable {
private var htmlString: String
private var maxWidth: CGFloat = 0
private var font: UIFont = .systemFont(ofSize: 14)
private var textColor: UIColor = .darkText
init(htmlString: String) {
self.htmlString = htmlString
}
func makeUIView(context: UIViewRepresentableContext<HTMLTextView>) -> UITextView {
let textView = UITextView()
textView.isScrollEnabled = false
textView.isEditable = false
textView.backgroundColor = .clear
update(textView: textView)
return textView
}
func updateUIView(_ textView: UITextView, context: UIViewRepresentableContext<HTMLTextView>) {
update(textView: textView)
}
func sizeToFit(width: CGFloat) -> Self {
var textView = self
textView.maxWidth = width
return textView
}
func font(_ font: UIFont) -> Self {
var textView = self
textView.font = font
return textView
}
func textColor(_ textColor: UIColor) -> Self {
var textView = self
textView.textColor = textColor
return textView
}
// MARK: - Private
private func update(textView: UITextView) {
textView.attributedText = buildAttributedString(fromHTML: htmlString)
// this is one of the options that don't work
let size = textView.sizeThatFits(CGSize(width: width, height: .greatestFiniteMagnitude))
textView.frame.size = size
}
private func buildAttributedString(fromHTML htmlString: String) -> NSAttributedString {
let htmlData = Data(htmlString.utf8)
let options = [NSAttributedString.DocumentReadingOptionKey.documentType: NSAttributedString.DocumentType.html]
let attributedString = try? NSMutableAttributedString(data: htmlData, options: options, documentAttributes: nil)
let range = NSRange(location: 0, length: attributedString?.length ?? 0)
attributedString?.addAttributes([.font: font,
.foregroundColor: textColor],
range: range)
return attributedString ?? NSAttributedString(string: "")
}
}
It is called from the SwiftUI code like this:
HTMLTextView(htmlString: "some string with html tags")
.font(.systemFont(ofSize: 15))
.textColor(descriptionTextColor)
.sizeToFit(width: 200)
The idea is that the HTMLTextView would stick to the width (here 200, but in practice - the screen width) and grow vertically when the text is multiline.
The problem is whatever I do (see below), it is always displayed as a one line of text stretching outside of screen on the left and right. And it never grows vertically.
The stuff I tried:
calculating the size and setting the frame (you can see that in the code snippet)
doing the above + fixedSize() on the SwiftUI side
setting frame(width: ...) on the SwiftUI side
setting translatesAutoresizingMaskIntoConstraints to false
setting hugging priorities to required
setting ideal width on the SwiftUI side
Nothing helped. Any advice on how could I solve this will be very welcome!
P.S. I can't use SwiftUI's AttributedString, because I need to support iOS 14.
UPDATE:
I have removed all the code with maxWidth and calculating size. And added textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) when creating the textView in makeUIView(context:). This kind of solved the problem, except for this: even though the height of the text view is correct, the last line is not visible; if I rotate to landscape, it becomes visible; rotate to portrait - not visible again.
UPDATE 2:
After some trial and error I figured out that it is ScrollView to blame. HTMLTextView is inside VStack, which is inside ScrollView. When I remove scroll view, everything sizes correctly.
The problem is, I need scrolling when the content is too long.
So, in the end, I had to move calculating the size that the attributed string would take in the text view with the given font/size etc into the view model, and then set .frame(width:, height:) to those values.
Not ideal, as the pre-calculated height seems a little bit larger than the actual text's height, but could not find better solution for now.
Update (for readability):
I calculate the actual size in view model (calculateDescriptionSize(limitedToWidth maxWidth:), and then I use the result on the Swift UI view:
HTMLTextView(htmlString: viewModel.attributedDescription)
.frame(width: maxWidth, height: viewModel.calculateDescriptionSize(limitedToWidth: maxWidth).height)
where HTMLTextView is my custom view wrapping the UIKit text view.
And this is the size calculation:
func calculateDescriptionSize(limitedToWidth maxWidth: CGFloat) -> CGSize {
// source: https://stackoverflow.com/questions/54497598/nsattributedstring-boundingrect-returns-wrong-height
let textStorage = NSTextStorage(attributedString: attributedDescription)
let size = CGSize(width: maxWidth, height: CGFloat.greatestFiniteMagnitude)
let boundingRect = CGRect(origin: .zero, size: size)
let textContainer = NSTextContainer(size: size)
textContainer.lineFragmentPadding = 0
let layoutManager = NSLayoutManager()
layoutManager.addTextContainer(textContainer)
textStorage.addLayoutManager(layoutManager)
layoutManager.glyphRange(forBoundingRect: boundingRect, in: textContainer)
let rect = layoutManager.usedRect(for: textContainer)
return rect.integral.size
}

NSLayoutManager numberOfGlyphs always showing 0, Swift

I am trying to create a multipage PDF with UITextView on each page with the required attributed text. but When I am hitting a problem once the app is Archived and distributed via TestFlight for testing.
Below is a my sample codes which I used to generate the multi pages,
var textStorage = NSTextStorage()
textStorage = NSTextStorage(attributedString: attString)
let layoutManager = NSLayoutManager()
textStorage.addLayoutManager(layoutManager)
var pageSize = CGRect(x: 44, y: 108, width: 507, height: 690)
var lastGlyph = 0
while lastGlyph < layoutManager.numberOfGlyphs {
let textContainer = NSTextContainer()
let background = UINib(nibName: "background", bundle: nil).instantiate(withOwner: nil, options: nil)[0]
background.frame = pageRect
textContainer.size = subsequentPageSize.size
layoutManager.addTextContainer(textContainer)
let textView = UITextView(frame: pageSize, textContainer: textContainer)
pageSize.origin.x += pageSize.width
background.addSubview(textView)
context.beginPage()
background.layer.render(in: UIGraphicsGetCurrentContext()!)
lastGlyph = NSMaxRange(layoutManager.glyphRange(for: textContainer))
}
This works perfectly fine if run in the simulator or on device when built from Xcode but as soon as the app is distributed the layoutManager.numberOfGlyphs always returns 0 even if I print() the layoutmanager it shows,
<NSLayoutManager: 0x7ff313c9f6d0>
0 containers, text backing has 57 characters
Currently holding 57 glyphs.
Glyph tree contents: 57 characters, 57 glyphs, 1 nodes, 64 node bytes, 64 storage bytes, 128 total bytes, 2.25 bytes per character, 2.25 bytes per glyph
Layout tree contents: 57 characters, 57 glyphs, 0 laid glyphs, 0 laid line fragments, 1 nodes, 64 node bytes, 0 storage bytes, 64 total bytes, 1.12 bytes per character, 1.12 bytes per glyph, 0.00 laid glyphs per laid line fragment, 0.00 bytes per laid line fragment'.
Have I missed something silly or is there a bug that I am not aware of? I cannot for the life of me understand why it is not working!
Appreciate ay help that could be given.
I eventually found a solution in the NSTextStorage subclassing notes which state,
The NSTextStorage class implements change management (via the beginEditing() and endEditing() methods), verification of attributes, delegate handling, and layout management notification. The one aspect it does not implement is managing the actual attributed string storage, which subclasses manage by overriding the two NSAttributedString primitives...
So I have changed my code and added textStorage.beginEditing() and textStorage.endEditing() to the beginning and end of the entire sequence as follows and it now works once the project achieves as well as built directly to device or simulator from Xcode.
var textStorage = NSTextStorage()
textStorage.beginEditing()
if attribString != nil {
textStorage = NSTextStorage(attributedString: attribString)
} else {
textStorage = NSTextStorage(string: string)
}
let layoutManager = NSLayoutManager()
textStorage.addLayoutManager(layoutManager)
var pageSize = CGRect(x: 44, y: 108, width: 507, height: 690)
var lastGlyph = 0
while lastGlyph < layoutManager.numberOfGlyphs {
let textContainer = NSTextContainer()
let background = UINib(nibName: "background", bundle: nil).instantiate(withOwner: nil, options: nil)[0]
background.frame = pageRect
textContainer.size = subsequentPageSize.size
layoutManager.addTextContainer(textContainer)
let textView = UITextView(frame: pageSize, textContainer: textContainer)
background.addSubview(textView)
context.beginPage()
background.layer.render(in: UIGraphicsGetCurrentContext()!)
lastGlyph = NSMaxRange(layoutManager.glyphRange(for: textContainer))
}
textStorage.endEditing()

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.

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.

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)

Resources