NSAttributedString Drawing - How Many Lines? - ios

I am drawing text within a PDF using this method:
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.alignment = .center
paragraphStyle.lineBreakMode = .byWordWrapping
let textAttributes = [
NSAttributedString.Key.paragraphStyle: paragraphStyle,
NSAttributedString.Key.font: UIFont(name: "MySpecialFont", size: 16)!,
NSAttributedString.Key.foregroundColor: UIColor.black
]
let attributedText = NSAttributedString(
string: myTextArray.first,
attributes: textAttributes
)
let textRect = CGRect(
x: 400,
y: 40,
width: 80,
height: 40
)
attributedText.draw(in: textRect)
Above draws the text fine. However, sometimes, the string passed on to it seem to be too long and go on for 3 lines instead of 2. In these cases, I want to textRect to be taller. Basically to know how many lines it would take so textRect could be adjusted.
There are several functions within NSAttributedString that gives string length, but thats the length if it was in a single line.
Is there a way to know how many lines the final attributedText would take inside textRect?

Use NSAttributedString.boundingRect(with:options:context:) to compute the size required. You should pass .usesLineFragmentOrigin as an option so that it'll compute it for multiple lines. Pass the width you want; the height doesn't really matter, because it'll expand the height to contain the full string, and you can use that to work out your final rectangle.
That said, from your description, it sounds like you can just make the height very large (10,000 is the usual value; but maybe you want to just give room for three lines). Since you're just drawing here; it shouldn't matter if the available rect is taller than required. It only matters if it's shorter.

Related

NSAttributedString text always sticks to bottom with big lineHeight

I'm trying to implement by-design labels coming from Sketch e.g. I need text styles with font size = 19 and line height = 50. So I ended up using NSAttributedString with NSMutableParagraphStyle but was stopped by problem with text being sticked to bottom of UILabel
I've already tried to use lineHeightMultiple and lineSpacing but those didn't give me the line height I wanted so I ended up using minimumLineHeight and maximumLineHeight equal the same
Here is my approach to make NSAttributedString
private static func makeAttributedString(
with attributes: TextAttributes,
text: String? = nil,
alignment: NSTextAlignment = .center
) -> NSAttributedString {
let font = UIFont(name: attributes.font.rawValue, size: attributes.fontSize)!
let paragraph = NSMutableParagraphStyle()
paragraph.alignment = alignment
paragraph.paragraphSpacing = attributes.paragraph
paragraph.minimumLineHeight = attributes.lineHeight // equal 50 in my case
paragraph.maximumLineHeight = attributes.lineHeight // equal 50 in my case
let attributes: [NSAttributedStringKey: Any] = [
NSAttributedStringKey.paragraphStyle: paragraph,
NSAttributedStringKey.foregroundColor: attributes.textColor,
NSAttributedStringKey.kern: attributes.kern,
NSAttributedStringKey.font: font
]
return NSAttributedString(string: text ?? "", attributes: attributes)
}
I expect result similar to design
but actually getting
Note: setting height constraint to 50 is not applicable because I also need multiline labels but there is the same bug with them
Seems like I've found some workaround myself, maybe it will help someone.
The method is about setting baselineOffset like this:
NSAttributedStringKey.baselineOffset: (attributes.lineHeight - font.lineHeight) / 4
Works like charm:
https://i.imgur.com/a2EOf5R.png

Bounding rect of multiline string in UILabel swift

I have been trying for hours now to find the boundingRect of a string in a UILabel I have, but nothing seems to be working.
From what I understand, boundingRect returns the size of the actual text in the label, not the label's size or something like that. This is true, right?
I have a UILabel called messageLabel which contains some text that wraps to an unlimited number of lines.
The code I have now is this:
let labelRect = (message as NSString).boundingRect(with: messageLabel.frame.size,
options: .usesLineFragmentOrigin,
attributes: [NSFontAttributeName : messageLabel.font],
context: nil)
Unfortunately, this returns totally wrong dimensions for my text.
What is the correct way to return the dimensions of text in a multiline UILabel?
Use:
let sizeToFit = CGSize(width: messageLabel.frame.size.width,
height: CGFloat.greatestFiniteMagnitude)
let textSize = messageLabel.sizeThatFits(sizeToFit)
Anyway, the way you did it should work as well (you can see on playground both functions return same size):
I've added a sample view to the playground, so you can see, the label has black border, and the text fits inside, and is smaller than label. Size is computer properly with both sizeToFit and boundingRect methods (but boundingRect returns not rounded values). I've use this computed size to create a green background view under the text, and it fits it properly.
I think you need to Try this
let messageLabel = UILabel(frame: CGRect(x: 0, y: 0, width: _screenSize.width - 30, height: 5))
messageLabel.font = self.txtDescription.font
messageLabel.numberOfLines = 0
messageLabel.text = "Your Massage"
messageLabel.numberOfLines = 0
messageLabel.sizeToFit()
print(messageLabel.frame.size.height)
Remove all code Just try this Hope it will wirk

Align baselines with characters in large line heights with Text Kit

When I draw an attributed string with a fixed line height with Text Kit, the characters always get aligned to the bottom of the line fragment. While this would make sense on one line with characters varying in size, this breaks the flow of the text with multiple lines. The baselines appear decided by the largest descender for each line.
I've found an article from the people behind Sketch explaining this exact problem in a bit more detail and showing what their solution does, but obviously not explaining how they achieved this.
This is what I want basically:
When showing two lines with a large line height, this result is far from ideal:
The code I'm using:
let smallFont = UIFont.systemFont(ofSize: 15)
let bigFont = UIFont.systemFont(ofSize: 25)
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.minimumLineHeight = 22
paragraphStyle.maximumLineHeight = 22
var attributes = [
NSFontAttributeName: smallFont,
NSParagraphStyleAttributeName: paragraphStyle
]
let textStorage = NSTextStorage()
let textContainer = NSTextContainer(size: CGSize(width: 250, height: 500))
let layoutManager = NSLayoutManager()
textStorage.append(NSAttributedString(string: "It is a long established fact that a reader will be ", attributes:attributes))
attributes[NSFontAttributeName] = bigFont
textStorage.append(NSAttributedString(string: "distracted", attributes:attributes))
attributes[NSFontAttributeName] = smallFont
textStorage.append(NSAttributedString(string: " by the readable content of a page when looking at its layout.", attributes:attributes))
layoutManager.addTextContainer(textContainer)
textStorage.addLayoutManager(layoutManager)
let textView = UITextView(frame: self.view.bounds, textContainer:textContainer)
view.addSubview(textView)
I managed to get this working, but had to drop support for iOS 8 and macOS 10.10 unfortunately.
If you implement the following delegate call of the NSLayoutManager, you get to decide what to do with the baselineOffset for each line fragment:
optional func layoutManager(_ layoutManager: NSLayoutManager,
shouldSetLineFragmentRect lineFragmentRect: UnsafeMutablePointer<CGRect>,
lineFragmentUsedRect: UnsafeMutablePointer<CGRect>,
baselineOffset: UnsafeMutablePointer<CGFloat>,
in textContainer: NSTextContainer,
forGlyphRange glyphRange: NSRange) -> Bool
When the NSTextStorage is created and for each subsequent change, I enumerate all used font, calculate it's default line height (NSLayoutManager.defaultLineHeightForFont()) and store the biggest line height. In the implementation of the above mentioned delegate method I check the current line height of the NSParagraphStyle for the provided line fragment and align the font's line height within that value. From there the baseline offset can be calculated with the knowledge that the baseline sits between the font's ascender and descender. Update the baselineOffset value with baselineOffset.memory(newOffset) and everything should be aligned as you'd like.
Note: I'm not going in too much detail about the actual code used to implement this because I'm not sure I'm using the right values throughout these calculations. I might update this in the near future when the whole approach is tried and proven.
Update: Implementation of adjusting baseline. Every time the textContainer changes I recalculate the biggest line height and biggest descender. Then I basically do this in the layout manager's delegate function:
var baseline: CGFloat = (lineFragmentRect.pointee.height - biggestLineHeight) / 2
baseline += biggestLineHeight
baseline -= biggestDescender
baseline = min(max(baseline, 0), lineFragmentRect.pointee.height)
baselineOffset.pointee = floor(baseline)

NSAttributedString with tabs

How do you create a UILabel with this kind of text format? Would you use NSAttributedString?
NSAttributedString can create text columns with tab stops. This is similar to how it is done in a word processor with the same limitations.
let text = "Name\t: Johny\nGender\t: Male\nAge\t: 25\nFavourites\t: Reading, writing"
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.tabStops = [NSTextTab(textAlignment: NSTextAlignment.Left, location: 150, options: [:])]
paragraphStyle.headIndent = 150
label.attributedText = NSAttributedString(string: text, attributes: [NSParagraphStyleAttributeName: paragraphStyle])
tabStops provides point positions for where to continue text after each tab. Here we did one tab at a reasonable point after the first column.
headIndent tells the label that wrapped text needs to be indented by a fixed amount, so it wraps to the next line.
The limitations with this approach are:
The tab stop location is a fixed point value so you need to know what you want. If the value you pick is less than the width of the first column for some lines, those lines will indent to a different location.
Wrapping only really works if your last column is the one that wraps. Since your second column was prefaced by ":" You may want to either just increase your headIndent or also split out the ":" to be \t:\t and set up a second tab stop. If you're not letting text wrap, this is not an issue.
If these limitations are too restrictive, you can restructure your label to be a collection of multiple labels with auto layout constraints.
In Swift 4.2 or above
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.tabStops = [NSTextTab.init(textAlignment: .left, location: 150, options: [:])]
paragraphStyle.headIndent = 150
let attributedTitle = NSAttributedString(string: "Some Title", attributes: [NSAttributedString.Key.font: UIFont.systemFont(ofSize: 14.0), NSAttributedString.Key.paragraphStyle: paragraphStyle])

UITextView lineHeightMultiple Clips Top, first line, of Text

In iOS 8, I have a vanilla UITextView that clips the top of the 1st line when a lineHeightMultiple is applied to it's NSMutableParagraphStyle, see image below:
It appears as though lineHeightMultiple affects the 1st line of text in addition to subsequent lines.
Setting clipsToBounds = false on the UITextView will at least enable the clipped part to show, but you can see from the image below that now the top part of the text is obviously above it's frame:
I can fix this by just setting the top constraint on the offending UITextView to compensate for clipsToBounds = false but that feels like a hack to me.
I have also tried using a WKWebView for the offending text, and just setting the css line-height property, and that works just fine. There must simply be something I am missing when it comes to UITextView though.
Additionally, setting lineSpacing on the paragraph style to less than 0 has no affect, per the docs:
The distance in points between the bottom of one line fragment
and the top of the next.
**This value is always nonnegative.**
This value is included in the line fragment heights in the
layout manager.
I have also tried setting the contentInset of the UITextView as well as using a system font, both had not affect.
My sample code for this setup follows:
let text = "THIS IS A MULTILINE RUN OF TEXT"
let font = UIFont(name: "MyFontName", size: 31.0)!
// let font = UIFont.systemFontOfSize(31.0)
let paragraph = NSMutableParagraphStyle()
paragraph.lineHeightMultiple = 0.75
paragraph.alignment = NSTextAlignment.Center
let attributes = [
NSParagraphStyleAttributeName: paragraph,
NSFontAttributeName: font
]
titleView.attributedText = NSAttributedString(string: text, attributes: attributes)
// titleView.contentInset = UIEdgeInsets(top: 50.0, left: 0.0, bottom: 0.0, right: 0.0)
titleView.clipsToBounds = false
Has anyone encountered this and overcome or is the top constraint hack the only way to go?
I had the same issue.
I compensated it using NSBaselineOffsetAttributeName.
You should use:
let attributes = [
NSParagraphStyleAttributeName: paragraph,
NSFontAttributeName: font,
NSBaselineOffsetAttributeName: -5
]
You will have to also set a lower paragraph.lineHeightMultiple.
A little tricky, but it works.
Cooliopas is right, but I ended up using this in a label extension and needed something more suited for the many different sizes of text throughout my app. I found that the baseline adjustment to keep the text vertically centered was 10% of the height of the text.
let attrString = NSMutableAttributedString(string: newText)
let style = NSMutableParagraphStyle()
style.alignment = self.textAlignment
style.lineSpacing = 1.0
style.lineHeightMultiple = 0.75
var baselineOffset : CGFloat = -5 //-5 is a default for this in case there is no attributedText size to reference
if let size = self.attributedText?.size(){
baselineOffset = size.height * -0.1
} else {
NSLog("attributedText = nil, setting lineHeightMultiple to -5 as a default for ", newText)
}
attrString.addAttribute(NSParagraphStyleAttributeName, value: style, range: NSMakeRange(0, attrString.length))
attrString.addAttribute(NSBaselineOffsetAttributeName, value: baselineOffset, range: NSMakeRange(0, attrString.length))
self.clipsToBounds = false
self.attributedText = attrString
Alternative approach - use a stack view with two labels, and set the stack view spacing to the top label's font's descender.

Resources