How to calculate height of nsattributed string with line spacing dynamically - ios

im trying to calculate the height of a UILabel with LineSpacing attribute. The weird thing is that calculated value of the height of the normal label.text is lower then the label.attributedText with its lineheight. it looks like i'm doing something wrong, but cant find what, so please help :D.
The provided code is specially made for SO to make it compact and clear, it is implemented differently in my project.
extension NSAttributedString {
func heightWithWidth(width: CGFloat) -> CGFloat {
let maxSize = CGSize(width: width, height: CGFloat.max)
let boundingBox = self.boundingRectWithSize(maxSize, options: [.UsesLineFragmentOrigin, .UsesFontLeading, .UsesDeviceMetrics], context: nil)
return boundingBox.height
}
}
extension UILabel {
func getHeightWithGivenWidthAndLineHeight(lineHeight: CGFloat, labelWidth: CGFloat) -> CGFloat {
let text = self.text
if let text = text {
let attributeString = NSMutableAttributedString(string: text)
let style = NSMutableParagraphStyle()
style.lineSpacing = lineHeight
attributeString.addAttribute(NSParagraphStyleAttributeName, value: style, range: NSMakeRange(0, text.characters.count))
let height = attributeString.heightWithWidth(labelWidth)
self.attributedText = attributeString
return height
}
return 0
}
I call this by
let contentHeight = contentLabel.text! == "" ? 0 : contentLabel.getHeightWithGivenWidthAndLineHeight(3, labelWidth: labelWidth)
Working with normal strings (without spacing) works perfectly, when i use attributedstring with lineSpacing it fails to calculate the correct value.

You can just use UILabel's sizeThatFits. For example:
let text = "This is\nSome\nGreat\nText"
let contentHeight = contentLabel.text! == "" ? 0 : contentLabel.getHeightWidthGivenWidthAndLineHeight(6, labelWidth: labelWidth)
//returns 73.2
But just setting
contentLabel.attributedText = contentLabel.attributedString //attributedString is same as getHeightWidthGivenWidthAndLineHeight
let size = contentLabel.sizeThatFits(contentLabel.frame.size)
//returns (w 49.5,h 99.5)
Code for attributedString added to your extension, if you need to see that:
var attributedString:NSAttributedString?{
if let text = self.text{
let attributeString = NSMutableAttributedString(string: text)
let style = NSMutableParagraphStyle()
style.lineSpacing = 6
attributeString.addAttribute(NSParagraphStyleAttributeName, value: style, range: NSMakeRange(0, text.characters.count))
return attributeString
}
return nil
}

I updated my Extension this way to set the line height and return the new label height at the same time. Thanx to beyowulf
extension UILabel {
func setLineHeight(lineHeight: CGFloat, labelWidth: CGFloat) -> CGFloat {
let text = self.text
if let text = text {
let attributeString = NSMutableAttributedString(string: text)
let style = NSMutableParagraphStyle()
style.lineSpacing = lineHeight
attributeString.addAttribute(NSParagraphStyleAttributeName, value: style, range: NSMakeRange(0, text.characters.count))
self.attributedText = attributeString
return self.sizeThatFits(CGSize(width: labelWidth, height: 20)).height
}
return 0
}
}

Related

Emoji support for NSAttributedString attributes (kerning/paragraph style)

I am using a kerning attribute on a UILabel to display its text with some custom letter spacing. Unfortunately, as I'm displaying user-generated strings, I sometimes see things like the following:
ie sometimes some emoji characters are not being displayed.
If I comment out the kerning but apply some paragraph style instead, I get the same kind of errored rendering.
I couldn't find anything in the documentation explicitely rejecting support for special unicode characters. Am I doing something wrong or is it an iOS bug?
The code to reproduce the bug is available as a playground here: https://github.com/Bootstragram/Playgrounds/tree/master/LabelWithEmoji.playground
and copied here:
//: A UIKit based Playground for presenting user interface
import UIKit
import PlaygroundSupport
extension NSAttributedString {
static func kernedSpacedText(_ text: String,
letterSpacing: CGFloat = 0.0,
lineHeight: CGFloat? = nil) -> NSAttributedString {
// TODO add the font attribute
let attributedString = NSMutableAttributedString(string: text)
attributedString.addAttribute(NSAttributedStringKey.kern,
value: letterSpacing,
range: NSRange(location: 0, length: text.count))
if let lineHeight = lineHeight {
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.lineSpacing = lineHeight
attributedString.addAttribute(NSAttributedStringKey.paragraphStyle,
value: paragraphStyle,
range: NSRange(location: 0, length: text.count))
}
return attributedString
}
}
//for familyName in UIFont.familyNames {
// for fontName in UIFont.fontNames(forFamilyName: familyName) {
// print(fontName)
// }
//}
class MyViewController : UIViewController {
override func loadView() {
let view = UIView()
view.backgroundColor = .white
let myString = "1βš½πŸ“ΊπŸ»βšΎοΈπŸŒ―πŸ„β€β™‚οΈπŸ‘\n2 πŸ˜€πŸ’ΏπŸ’Έ 🍻"
let label = UILabel()
label.frame = CGRect(x: 150, y: 200, width: 200, height: 100)
label.attributedText = NSAttributedString.kernedSpacedText(myString)
label.numberOfLines = 0
label.textColor = .black
view.addSubview(label)
self.view = view
}
}
// Present the view controller in the Live View window
PlaygroundPage.current.liveView = MyViewController()
Thanks.
TL, DR:
String.count != NSString.length. Any time you see NSRange, you must convert your String into UTF-16:
static func kernedSpacedText(_ text: String,
letterSpacing: CGFloat = 0.0,
lineHeight: CGFloat? = nil) -> NSAttributedString {
// TODO add the font attribute
let attributedString = NSMutableAttributedString(string: text)
attributedString.addAttribute(NSAttributedStringKey.kern,
value: letterSpacing,
range: NSRange(location: 0, length: text.utf16.count))
if let lineHeight = lineHeight {
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.lineSpacing = lineHeight
attributedString.addAttribute(NSAttributedStringKey.paragraphStyle,
value: paragraphStyle,
range: NSRange(location: 0, length: text.utf16.count))
}
return attributedString
}
The longer explanation
Yours is a common problem converting between Swift's String and ObjC's NSString. The length of a String is the number of extended grapheme clusters; in ObjC, it's the number of UTF-16 code points needed to encode that string.
Take the thumb-up character for example:
let str = "πŸ‘"
let nsStr = str as NSString
print(str.count) // 1
print(nsStr.length) // 2
Things can get even weirder when it comes to the flag emojis:
let str = "πŸ‡ΊπŸ‡Έ"
let nsStr = str as NSString
print(str.count) // 1
print(nsStr.length) // 4
Even though this article was written all the way back in 2003, it's still a good read today:
The Absolute Minimum Every Software Developer Absolutely, Positively Must Know About Unicode and Character Sets.

When I tap on UITextView at a particular place I want to change the color of that particular line

I have a textView
When I tap on a particular area of the textview I find the line number but I can not get the text of that line
I need to get the text of a particular line and change only that text's color in the textview
func HandleTapped(sender: UITapGestureRecognizer)
{
print("tapped")
if sender.state == .recognized {
let location = sender.location(ofTouch: 0, in: TextView)
print(location)
if location.y >= 0 && location.y <= TextView.contentSize.height {
guard let font = TextView.font else {
return
}
let line = Int((location.y - TextView.textContainerInset.top) / font.lineHeight) + 1
print("Line is \(line)")
let text=TextView.textContainer
print(text)
}
}
}
Use this extension to UITextView to get the line of text at the current line selected:
func getLineString() -> String {
return (self.text! as NSString).substringWithRange((self.text! as NSString).lineRangeForRange(self.selectedRange))
}
Then from there you'll need to change all the text to attributed text and change just the range of your selected line text to your highlight color. Something like:
let allText = textView.text
let lineText = textView.getLineString()
let attrText = NSMutableAttributedString(string: allText)
let regularFont = UIFont(name: "Arial", size: 30.0)! // Make this whatever you need
let highlightFont = UIFont(name: "Arial-BoldMT", size: 30.0)! // Make this whatever you need
// Convert allText to NSString because attrText.addAttribute takes an NSRange.
let allTextRange = (allText as NSString).rangeOfString(allText)
let lineTextRange = (allText as NSString).rangeOfString(lineText)
attrText.addAttribute(NSFontAttributeName, value: regularFont, range: allTextRange)
attrText.addAttribute(NSFontAttributeName, value: highlightFont, range: lineTextRange)
textView.attributedText = attrText

Calculating attributed string height

I'm trying to set a tableViewCell height equal to the height of the attributedString inside the cell. However whatever I do it does not seem to have the correctSize. this is what I've tried so far
cellHeight
//Convert description to NSAttributedString and get height
//width is equal to screen width - right and left offset
//at the end add the bottom and height offset to the cell height
return detailPetViewModel!.description
.lineSpacing(spacing: 4)
.heightWithConstrainedWidth(width: Sizes.screenWidth - 40) + 10
Customize descLabel in cell subclass
//Customize descLabel
descLabel.font = FontFamily.Avenir.Regular.font(size: 16)
descLabel.textColor = UIColor(named: .SecondaryTextColor)
//Multiple lines
descLabel.numberOfLines = 0
descLabel.lineBreakMode = .byWordWrapping
descLabel.sizeToFit()
linespacing extension
extension String {
func lineSpacing(spacing: CGFloat) -> NSAttributedString {
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.lineSpacing = spacing
let attributedString = NSMutableAttributedString(string: self)
attributedString.addAttribute(NSParagraphStyleAttributeName, value:paragraphStyle, range:NSMakeRange(0, attributedString.length))
return attributedString
}
}
height Extension
extension NSAttributedString {
func heightWithConstrainedWidth(width: CGFloat) -> CGFloat {
let constraintRect = CGSize(width: width, height: .greatestFiniteMagnitude)
let boundingBox = self.boundingRect(with: constraintRect, options: .usesLineFragmentOrigin, context: nil)
return boundingBox.height
}
}
This is the extension I use. You shouldn't have to pass in any information about the font or line-height as the attributed string already knowns its values.
extension NSAttributedString {
func height(containerWidth: CGFloat) -> CGFloat {
let rect = self.boundingRect(with: CGSize.init(width: containerWidth, height: CGFloat.greatestFiniteMagnitude),
options: [.usesLineFragmentOrigin, .usesFontLeading],
context: nil)
return ceil(rect.size.height)
}
func width(containerHeight: CGFloat) -> CGFloat {
let rect = self.boundingRect(with: CGSize.init(width: CGFloat.greatestFiniteMagnitude, height: containerHeight),
options: [.usesLineFragmentOrigin, .usesFontLeading],
context: nil)
return ceil(rect.size.width)
}
}
Used like this:
let height = detailPetViewModel!.description.height(containerWidth: Sizes.screenWidth - 40 + 10)
You can simply use (the instance of NSAttributedString).size.height
e.g.
let a = NSAttributedString(string: "A string", attributes: [NSAttributedString.Key.font: UIFont.boldSystemFont(ofSize: 14)])
let height = a.size().height

Wrong text height when text contains emoji

Following the official docs, I created this function to calculate text height.
func calculateTextHeight(myString: String, myWidth: CGFloat, myFont: UIFont) -> CGFloat {
let textStorage = NSTextStorage(string: myString)
let textContainer = NSTextContainer(size: CGSize(width: myWidth, height: CGFloat.max))
let layoutManager = NSLayoutManager()
layoutManager.addTextContainer(textContainer)
textStorage.addLayoutManager(layoutManager)
textStorage.addAttribute(NSFontAttributeName, value: myFont, range: NSMakeRange(0, textStorage.length))
textContainer.lineFragmentPadding = 0
textContainer.lineBreakMode = .ByWordWrapping
layoutManager.glyphRangeForTextContainer(textContainer)
return layoutManager.usedRectForTextContainer(textContainer).size.height
}
But the calculated height is wrong when the text contains an emoji.
var s = "ABCDE 12345"
print(calculateTextHeight(s, myWidth: 500, myFont: UIFont.systemFontOfSize(14)))
// prints 16.7 (correct)
s = "ABCDE 12345 πŸ’©"
print(calculateTextHeight(s, myWidth: 500, myFont: UIFont.systemFontOfSize(14)))
// prints 22.9 (should be 16.7)
Is this a bug? How can I fix this?
This worked for me when text contains emojis. For NSAttributedString strings:
extension NSAttributedString {
func sizeOfString(constrainedToWidth width: Double) -> CGSize {
let framesetter = CTFramesetterCreateWithAttributedString(self)
return CTFramesetterSuggestFrameSizeWithConstraints(framesetter, CFRange(location: 0, length: 0), nil, CGSize(width: width, height: .greatestFiniteMagnitude), nil)
}
}
For String:
extension String {
func sizeOfString(constrainedToWidth width: Double, font: UIFont) -> CGSize {
let attributes = [NSAttributedString.Key.font : font]
let attString = NSAttributedString(string: self, attributes: attributes)
let framesetter = CTFramesetterCreateWithAttributedString(attString)
return CTFramesetterSuggestFrameSizeWithConstraints(framesetter, CFRange(location: 0, length: 0), nil, CGSize(width: width, height: .greatestFiniteMagnitude), nil)
}
}
I used an alternate method to calculate text height. This works with emojis.
static func calculateStringHeight(str: String, maxWidth: CGFloat, font: UIFont) -> CGFloat {
return str.boundingRectWithSize(CGSizeMake(maxWidth, CGFloat.max), options: NSStringDrawingOptions.UsesLineFragmentOrigin, attributes: [NSFontAttributeName: font], context: nil).height
}
I do not think it is a bug. Emoji take up more space to be displayed.
I believe this will make a difference only if the number of emoji in your text is too large.
If you try the code below, i think that the result will be the same.
s = "ABCDE 12345 πŸ’©πŸ’©πŸ’©πŸ’©πŸ’©πŸ’©πŸ’©"
print(calculateTextHeight(s, myWidth: 500, myFont: UIFont.systemFontOfSize(14)))
// prints 22.9
If you want to eliminate the emoji, you can remove them from the original text before doing the height calculation.
In this case, you need to scan the original text by replacing all emoji by other character, and then call the height calculation.

UILabel get current scale factor when minimumScaleFactor was set?

I have a UILabel and set:
let label = UILabel()
label.minimumScaleFactor = 10 / 25
After setting the label text I want to know what the current scale factor is. How can I do that?
You also need to know what is the original font size, but I guess you can find it in some way 😊
That said, use the following func to discover the actual font size:
func getFontSizeForLabel(_ label: UILabel) -> CGFloat {
let text: NSMutableAttributedString = NSMutableAttributedString(attributedString: label.attributedText!)
text.setAttributes([NSFontAttributeName: label.font], range: NSMakeRange(0, text.length))
let context: NSStringDrawingContext = NSStringDrawingContext()
context.minimumScaleFactor = label.minimumScaleFactor
text.boundingRect(with: label.frame.size, options: NSStringDrawingOptions.usesLineFragmentOrigin, context: context)
let adjustedFontSize: CGFloat = label.font.pointSize * context.actualScaleFactor
return adjustedFontSize
}
//actualFontSize is the size, in points, of your text
let actualFontSize = getFontSizeForLabel(label)
//with a simple calc you'll get the new Scale factor
print(actualFontSize/originalFontSize*100)
You can solve this problem this way:
Swift 5
extension UILabel {
var actualScaleFactor: CGFloat {
guard let attributedText = attributedText else { return font.pointSize }
let text = NSMutableAttributedString(attributedString: attributedText)
text.setAttributes([.font: font as Any], range: NSRange(location: 0, length: text.length))
let context = NSStringDrawingContext()
context.minimumScaleFactor = minimumScaleFactor
text.boundingRect(with: frame.size, options: .usesLineFragmentOrigin, context: context)
return context.actualScaleFactor
}
}
Usage:
label.text = text
view.setNeedsLayout()
view.layoutIfNeeded()
// Now you will have what you wanted
let actualScaleFactor = label.actualScaleFactor
Or if you are interested in synchronizing the font size of several labels after shrinking, then I answered here https://stackoverflow.com/a/58376331/9024807

Resources