Vertically aligning NSTextAttachment in NSMutableAttributedString - ios

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)

Related

How to get the height of a paragraph using PDFKit

I am writing a pdf using iOS PDFKit. Typically I can get the height of a single text item such as a title by doing the following:
return titleStringRect.origin.y + titleStringRect.size.height
Where titleStringRect is the CGRect containing the string. The returned value is the y-coordinate for the bottom of that text so that I know where to start writing the next line of text.
I have not found a way to know where a paragraph ends. The solutions I have found have been to just make a big enough CGRect that the paragraph will definitely fit in.
I need to know exactly what the height of the CGRect should be based on the String that will be written into it. Here is my code:
func addParagraph(pageRect: CGRect, textTop: CGFloat, text: String) {
let textFont = UIFont(name: "Helvetica", size: 12)
let backupFont = UIFont.systemFont(ofSize: 12, weight: .regular)
// Set paragraph information. (wraps at word breaks)
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.alignment = .natural
paragraphStyle.lineBreakMode = .byWordWrapping
// Set the text attributes
let textAttributes = [
NSAttributedString.Key.paragraphStyle: paragraphStyle,
NSAttributedString.Key.font: textFont ?? backupFont
]
let attributedText = NSAttributedString(
string: text,
attributes: textAttributes
)
let textRect = CGRect(
x: 50.0,
y: textTop,
width: pageRect.width - 100,
height: pageRect.height - textTop - pageRect.height / 5.0
)
attributedText.draw(in: textRect)
}
As you can see the above code just makes a CGRect that is 1/5th of the space below the previous text regardless of how many lines the paragraph will actually be.
I have tried averaging the character count per line in order to estimate how many lines the paragraph will be but this is unreliable and definitely a hack.
What I need is for the addParagraph function to return the y-coordinate for the bottom of the paragraph so that I know where to start writing the next piece of content.
I ended up finding the solution to this and it is pretty simple. I'll post the code and then explain it for anyone else who has this problem.
let paragraphSize = CGSize(width: pageRect.width - 100, height: pageRect.height)
let paragraphRect = attributedText.boundingRect(with: paragraphSize, options: NSStringDrawingOptions.usesLineFragmentOrigin, context: nil)
First define a CGSize that is a certain width and height. Set the width to the width you want the paragraph to be and set the height to a large value that will fit the content. Then call
attributedText.boundingRect(with: paragraphSize, options: NSStringDrawingOptions.usesLineFragmentOrigin, context: nil)
Where attributedText is the paragraph content. The boundingRect method returns a CGRect which is the size required to fit the content into, but no more. Now you can return the bottom of the paragraph. This method will not change the width unless it cannot fit the String into the height you provided. For my purpose this was perfect. Here is the full code:
func addParagraph(pageRect: CGRect, textTop: CGFloat, paragraphText: String) -> CGFloat {
let textFont = UIFont(name: "Helvetica", size: 12)
let backupFont = UIFont.systemFont(ofSize: 12, weight: .regular)
// Set paragraph information. (wraps at word breaks)
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.alignment = .natural
paragraphStyle.lineBreakMode = .byWordWrapping
// Set the text attributes
let textAttributes = [
NSAttributedString.Key.paragraphStyle: paragraphStyle,
NSAttributedString.Key.font: textFont ?? backupFont
]
let attributedText = NSAttributedString(
string: paragraphText,
attributes: textAttributes
)
// determine the size of CGRect needed for the string that was given by caller
let paragraphSize = CGSize(width: pageRect.width - 100, height: pageRect.height)
let paragraphRect = attributedText.boundingRect(with: paragraphSize, options: NSStringDrawingOptions.usesLineFragmentOrigin, context: nil)
// Create a CGRect that is the same size as paragraphRect but positioned on the pdf where we want to draw the paragraph
let positionedParagraphRect = CGRect(
x: 50,
y: textTop,
width: paragraphRect.width,
height: paragraphRect.height
)
// draw the paragraph into that CGRect
attributedText.draw(in: positionedParagraphRect)
// return the bottom of the paragraph
return positionedParagraphRect.origin.y + positionedParagraphRect.size.height
}

Add highlight/background to only text using Swift

I want to highlight or add a background only on a text on a label that is not center-aligned.
I already tried Attributed Strings (https://stackoverflow.com/a/38069772/676822) and using regex but didn't get near a good solution.
NSAttributedString won't work because my label is not centered and it doesn't contain line breaks. It's just a long text that takes multiple lines.
This is what I'm trying to accomplish:
Note: It's not "Evangelizing\nDesign\nThinking" it's "Evangelizing Design Thinking"
Thanks!
As far as I have tried its not possible to get what you want simply with attributed text because using:
let attributedText = NSMutableAttributedString(string: "Evangelizing Desing Thinking",
attributes: [
.font: UIFont.systemFont(ofSize: 14),
.backgroundColor: UIColor.gray
]
)
Will add extray gray background at the end of each line. My previous answer was not good neither because it only adds a gray background on each word, not on spaces, and as #Alladinian noticed, ranges can be wrong in some cases.
So here is a hack you can use to achieve what you want. It uses multiple labels but it can be easily improved by putting labels in a custom view. So, in your viewDidLoad / CustomView function add:
// Maximum desired width for your text
let maxLabelWidth: CGFloat = 80
// Font you used
let font = UIFont.systemFont(ofSize: 14)
// Your text
let text = "Eva ngel izing Des ing a Thin king"
// Width of a space character
let spaceWidth = NSString(string: " ").size(withAttributes: [NSAttributedString.Key.font: font]).width
// Width of a row
var currentRowWidth: CGFloat = 0
// Content of a row
var currentRow = ""
// Previous label added (we keep it to add constraint betweeen labels)
var prevLabel: UILabel?
let subStrings = text.split(separator: " ")
for subString in subStrings {
let currentWord = String(subString)
let nsCurrentWord = NSString(string: currentWord)
// Width of the new word
let currentWordWidth = nsCurrentWord.size(withAttributes: [NSAttributedString.Key.font: font]).width
// Width of the row if you add a new word
let currentWidth = currentRow.count == 0 ? currentWordWidth : currentWordWidth + spaceWidth + currentRowWidth
if currentWidth <= maxLabelWidth { // The word can be added in the current row
currentRowWidth = currentWidth
currentRow += currentRow.count == 0 ? currentWord : " " + currentWord
} else { // Its not possible to add a new word in the current row, we create a label with the current row content
prevLabel = generateLabel(with: currentRow,
font: font,
prevLabel: prevLabel)
currentRowWidth = currentWordWidth
currentRow = currentWord
}
}
// Dont forget to add the last row
generateLabel(with: currentRow,
font: font,
prevLabel: prevLabel)
Then you have to create the generateLabel function:
#discardableResult func generateLabel(with text: String,
font: UIFont,
prevLabel: UILabel?) -> UILabel {
let leftPadding: CGFloat = 50 // Left padding of the label
let topPadding: CGFloat = 100 // Top padding of (first) label
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
self.view.addSubview(label)
label.leftAnchor.constraint(equalTo: self.view.leftAnchor, constant: leftPadding).isActive = true
if let prevLabel = prevLabel {
label.topAnchor.constraint(equalTo: prevLabel.bottomAnchor).isActive = true
} else {
label.topAnchor.constraint(equalTo: self.view.topAnchor, constant: topPadding).isActive = true
}
label.font = font
label.text = text
label.backgroundColor = .gray
return label
}
Previous answer:
As Yogesh suggested, you can use attributed string:
// Init label
let label = UILabel(frame: CGRect(x: 50, y: 50, width: 90, height: 120))
self.view.addSubview(label)
label.lineBreakMode = .byTruncatingTail
label.numberOfLines = 0
label.backgroundColor = .white
// Create attributed text
let text = "Evangelizing Desing Thinking"
let attributedText = NSMutableAttributedString(string: text,
attributes: [
.font: UIFont.systemFont(ofSize: 14)
]
)
// Find ranges of each word
let subStrings = text.split(separator: " ")
let ranges = subStrings.map { (subString) -> Range<String.Index> in
guard let range = text.range(of: subString) else {
fatalError("something wrong with substring") // This case should not happen
}
return range
}
// Apply background color for each word
ranges.forEach { (range) in
let nsRange = NSRange(range, in: text)
attributedText.addAttribute(.backgroundColor, value: UIColor.gray, range: nsRange)
}
// Finally set attributed text
label.attributedText = attributedText

How to set width of UIImageView based on UILabel row width

I have recently begun work on an app using Swift in Xcode and am trying to create a text bubble. To do this, I need to get the width of the longest row of text in a multi-row UILabel. For example, if I have this text (I automatically set line breaks after a certain length):
Hello there, this is
an example piece of text
I would like to return the width of the text in the second row. I have already tried using sizeToFit() which would drastically simplify my work, but because of my other code, this is not an option as it causes other problems (my code is below). Is there a purely programmatic way to get this value without using sizeToFit()? Any help would be much appreciated. My code:
bubbleContents.text = textMessage
bubbleContents.numberOfLines = 0
bubbleContents.lineBreakMode = .byWordWrapping
bubbleContents.bounds.size.width = 2000
var widthText = bubbleContents.intrinsicContentSize.width
bubbleContents.bounds.size.width = 266
print(textMessage)
print(widthText)
if widthText > 266 {
let numRows = Int(widthText/266)
print(numRows)
//bubbleContents.frame.origin.y += CGFloat((Double(numRows)*10.25))
var currentHeight = 44.0
currentHeight += Double((Double(numRows)*20.5))
bubbleContents.bounds.size.height = CGFloat(currentHeight)
heightOfCell = Double(currentHeight)
let originalTransform = self.bubbleContents.transform
let scaledTransform = originalTransform
let scaledAndTranslatedTransform = scaledTransform.translatedBy(x: 0, y: CGFloat(Double(numRows)*20.5))
//self.bubbleContents.transform = scaledAndTranslatedTransform
}
else {
heightOfCell = 44.0
}
bubble.frame = CGRect(x: 0, y: 0, width: Double(widthText + 30), height: heightOfCell - 4)
bubbleContents.center.y = bubble.center.y
Here is an image of what my current text bubbles look like:
You can use NSAttributedString,boundingRect(with:options:context:) method, begin by creating NSAttributedString with attributes such as font of your UILabel
let attributes: [NSAttributedString.Key : Any] = [.font: bubbleContents.font]
let atStr = NSAttributedString(string: textMessage, attributes: attributes)
Now use atStr.boundingRect(with:options:context:) method, like so:
let bounds = CGSize(width: 266.0, height: .greatestFiniteMagnitude)
let bubbleSize = atStr.boundingRect(with: bounds, options: [.usesLineFragmentOrigin, .usesFontLeading, .usesDeviceMetrics], context: nil).size
Usage:
bubble.frame.size.width = bubbleSize.width
bubble.frame.size.height = max(bubbleSize.height, 44.0)

Swift iOS -How to Set NSTextAttachment Content Mode to Aspect Fit?

I have a PDF image that I appended to a String. I used NSTextAttachment and NSAttributedString to get it done. I add them to a textView and the result is Hello with an image of the World underneath of it.
The problem is when I set the bounds for the PDF image on the textAttachment the World image is distorted. It's stretched long and wide.
How can I set a contentMode on the textAttachment object to redraw the image correctly using .aspectRatio?
number #4 is where I set the bounds
// #1. Define dict attribute for string
let bold17 = [NSFontAttributeName: UIFont.boldSystemFont(ofSize: 17)]
// #2. Create "hello" string and add the dict attribute to it
let helloStr = NSAttributedString(string: "Hello\n\n", attributes: bold17)
// #3. Create NSTextAttachment
let textAttachment = NSTextAttachment()
// #4. Add image to the textAttachment then set it's bounds
textAttachment.image = UIImage(named: "world_PDF")
textAttachment.bounds = CGRect(x: 0, y: 0, width: 200, height: 200)
// #5. Set image as NSAttributedString
let worldImage = NSAttributedString(attachment: textAttachment)
// #6. Create NSMutableString to
let mutableAttributedString = NSMutableAttributedString()
// #7. Append the "hello" string and the "world" image to each other using the mutableAttributedString object
mutableAttributedString.append(helloStr)
mutableAttributedString.append(worldImage)
// #8. Set the mutableAttributedString to the textView then center it
textView.attributedText = mutableAttributedString
textView.textAlignment = .center
I followed #Maciej Swic's answer
Resize NSTextAttachment Image
For some reason I couldn't extend the NSTextAttachment class so I added it to the bottom of the class I was using it in. I removed the bounds property that I used in my question and used his function instead. It's on #4, the second line:
class MyController: UIViewController{
override func viewDidLoad() {
super.viewDidLoad()
// #1. Define dict attribute for string
let bold17 = [NSFontAttributeName: UIFont.boldSystemFont(ofSize: 17)]
// #2. Create "hello" string and add the dict attribute to it
let helloStr = NSAttributedString(string: "Hello\n\n", attributes: bold17)
// #3. Create NSTextAttachment
let textAttachment = NSTextAttachment()
// #4. Add image to the textAttachment then set it's bounds
textAttachment.image = UIImage(named: "world_PDF")
textAttachment.setImageHeight(height: 200) // <----HIS ANSWER HERE
// #5. Set image as NSAttributedString
let worldImage = NSAttributedString(attachment: textAttachment)
// #6. Create NSMutableString to
let mutableAttributedString = NSMutableAttributedString()
// #7. Append the "hello" string and the "world" image to each other using the mutableAttributedString object
mutableAttributedString.append(helloStr)
mutableAttributedString.append(worldImage)
// #8. Set the mutableAttributedString to the textView then center it
textView.attributedText = mutableAttributedString
textView.textAlignment = .center
}
}
extension NSTextAttachment {
func setImageHeight(height: CGFloat) {
guard let image = image else { return }
let ratio = image.size.width / image.size.height
bounds = CGRect(x: bounds.origin.x, y: bounds.origin.y, width: ratio * height, height: height)
}
}

Dynamically set UILabel text alignment between .left and .justified

In my app I have a UILabel with two lines preset. I can set the text alignment to either .left or .justified.
If I set it to .left, there is no layout issue if there is enough space between the last word in a line and the maximum x position of the label. Yet, when there is not so much space, so that the last word is very near the maximum x position, it looks kinda weird, because it is not exactly right-aligned (as it would be with .justified.
If I set it to .justified, it is always aligned well, yet sometimes the distance between the individual characters looks weird.
What I'm looking for is a way to dynamically adjust the text alignment depending on the distance between the last word in the first line to the maximum x position of the label. Say, if the position of the last character of the last word is smaller than 50, I want to have text alignment .left, otherwise I'd like to have .justified. Is there any way on how to accomplish this?
I took a quite hacky approach which takes some processing power, but it seems to work.
First of all, I fetch the string in the first line of the label using this extension:
import CoreText
extension UILabel {
/// Returns the String displayed in the first line of the UILabel or "" if text or font is missing
var firstLineString: String {
guard let text = self.text else { return "" }
guard let font = self.font else { return "" }
let rect = self.frame
let attStr = NSMutableAttributedString(string: text)
attStr.addAttribute(String(kCTFontAttributeName), value: CTFontCreateWithName(font.fontName as CFString, font.pointSize, nil), range: NSMakeRange(0, attStr.length))
let frameSetter = CTFramesetterCreateWithAttributedString(attStr as CFAttributedString)
let path = CGMutablePath()
path.addRect(CGRect(x: 0, y: 0, width: rect.size.width + 7, height: 100))
let frame = CTFramesetterCreateFrame(frameSetter, CFRangeMake(0, 0), path, nil)
guard let line = (CTFrameGetLines(frame) as! [CTLine]).first else { return "" }
let lineString = text[text.startIndex...text.index(text.startIndex, offsetBy: CTLineGetStringRange(line).length-2)]
return lineString
}
}
After that I calculate the width, a label with line number 1 and fixed height would require for that string using this extension:
extension UILabel {
/// Get required width for a UILabel depending on its text content and font configuration
class func calculateWidth(text: String, height: CGFloat, font: UIFont) -> CGFloat {
let label = UILabel(frame: CGRect(x: 0, y: 0, width: CGFloat.greatestFiniteMagnitude, height: height))
label.numberOfLines = 1
label.font = font
label.text = text
label.sizeToFit()
return label.frame.size.width
}
}
Based on that, I can calculate the distance to the right and decide whether to choose text alignment .left or .justified, so the main code looks like this:
// Set text
myLabel.text = someString
// Change text alignment depending on distance to right
let firstLineString = myLabel.firstLineString
let distanceToRight = myLabel.frame.size.width - UILabel.calculateWidth(text: firstLineString, height: myLabel.frame.size.height, font: myLabel.font)
myLabel.textAlignment = distanceToRight < 20 ? .justified : .left

Resources