Dynamically set UILabel text alignment between .left and .justified - ios

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

Related

Multiline UILabel with automatic width prefers to use 1 line

I want to create a label with dynamic width. I know how to implement it in xib in usual cases.
But in my current case this label has fixed height, 2 max lines and unlimited width.
The problem is width unlimited, so iOS always writes the label in a single line. Is it possible to force fulfill the maximum number of lines first and only then increase the label size?
You can do this by using boundingRect(with:options:attributes:context:)...
Assuming you have string str:
calculate the height that 2 lines would require (use "1\n2", for example)
calculate the width that the str would require, if it was only a single line
divide that width by 2 (we'll call it halfWidth)
calculate the height of str limiting it's width to halfWidth
At this point, we've cut the width exactly in half, and that could (will almost certainly) cut a word in half. That means word-wrapping can result in the height being greater than the two-lines-height.
So we need to:
loop
incrementing halfWidth (by 8-pts seems reasonable)
get the new bounding box height
keep looping until the new height equals the two-lines-height
Here is a quick example. We'll use these strings for the label:
"First example.",
"String with some text.",
"This is a longer string for the two-line label.",
"Depending on the available width, we may run into problems if the text is too long.",
"Our final example string will be much longer than the others. This will demonstrate that, unless we also set a max-width, the calculated width will end up extending the label outside the bounds of our view (assuming we're on an iPhone in Portrait orientation).",
With each tap in the view, we'll calculate a "two-line-width" for the string and update the label's width constraint:
class ViewController: UIViewController {
var theLabel: UILabel = UILabel()
var labelWidthConstraint: NSLayoutConstraint!
let testStrings: [String] = [
"First example.",
"String with some text.",
"This is a longer string for the two-line label.",
"Depending on the available width, we may run into problems if the text is too long.",
"Our final example string will be much longer than the others. This will demonstrate that, unless we also set a max-width, the calculated width will end up extending the label outside the bounds of our view (assuming we're on an iPhone in Portrait orientation).",
]
var idx: Int = 0
override func viewDidLoad() {
super.viewDidLoad()
theLabel.translatesAutoresizingMaskIntoConstraints = false
// max of two lines
theLabel.numberOfLines = 2
// whatever font you want for your label
theLabel.font = .systemFont(ofSize: 16.0)
view.addSubview(theLabel)
let g = view.safeAreaLayoutGuide
// create the width constraint that we'll modify in updateLabel()
// using 100 here, but the initial value doesn't matter...
labelWidthConstraint = theLabel.widthAnchor.constraint(equalToConstant: 100.0)
NSLayoutConstraint.activate([
// let's put the label at 40,40
theLabel.topAnchor.constraint(equalTo: g.topAnchor, constant: 40.0),
theLabel.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 40.0),
labelWidthConstraint,
])
// so we can see the label frame
theLabel.backgroundColor = .green
// update the label with the first string from our array
let s = testStrings[idx]
updateLabel(s)
let t = UITapGestureRecognizer(target: self, action: #selector(gotTap(_:)))
view.addGestureRecognizer(t)
}
#objc func gotTap(_ g: UITapGestureRecognizer) -> Void {
// change the string and re-caculate the label on each tap
idx += 1
let s = testStrings[idx % testStrings.count]
updateLabel(s)
}
func updateLabel(_ str: String) -> Void {
guard let theLabelFont = theLabel.font else {
// this should never happen, but always a
// good idea to properly unwrap optionals
return
}
// get the calculated width
let calcWidth: CGFloat = calcTwoLineWidth(str, fnt: theLabelFont)
// update the label's width constraint constant
labelWidthConstraint.constant = calcWidth
// update the label's text
theLabel.text = str
}
func calcTwoLineWidth(_ str: String, fnt: UIFont) -> CGFloat {
// get the height of two lines
let twoLineHeight = "1\n2".height(withConstrainedWidth: .greatestFiniteMagnitude, font: fnt)
// get the width of the string as a single line
let oneLineWidth = str.width(withConstrainedHeight: .greatestFiniteMagnitude, font: fnt)
// start with 1/2 of the full width of the string
var halfWidth: CGFloat = ceil(oneLineWidth * 0.5)
// get the height of the string constrained to half width
var newHeight: CGFloat = str.height(withConstrainedWidth: halfWidth, font: fnt)
// the string may still wrap onto a third line, so increase the width
// until we only need two lines
while newHeight > twoLineHeight {
halfWidth += 8
newHeight = str.height(withConstrainedWidth: halfWidth, font: fnt)
}
return halfWidth
}
}
extension String {
func height(withConstrainedWidth width: CGFloat, font: UIFont) -> CGFloat {
let constraintRect = CGSize(width: width, height: .greatestFiniteMagnitude)
let boundingBox = self.boundingRect(with: constraintRect, options: .usesLineFragmentOrigin, attributes: [NSAttributedString.Key.font: font], context: nil)
return ceil(boundingBox.height)
}
func width(withConstrainedHeight height: CGFloat, font: UIFont) -> CGFloat {
let constraintRect = CGSize(width: .greatestFiniteMagnitude, height: height)
let boundingBox = self.boundingRect(with: constraintRect, options: .usesLineFragmentOrigin, attributes: [NSAttributedString.Key.font: font], context: nil)
return ceil(boundingBox.width)
}
}
And here's the results:
Notice that the final string is too long to fit on two lines within the bounds of our view -- which is, based on your description an comment, your desired goal.
Please note this is Example Code Only.

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

Scroll UITextView to specific text

i have a uitextview with large texts and above i have a search field. Anything searched, i would love the uitextview to scroll to that specific text and hightlight. And i need to make sure that the uitextview scrolls in such a way that the searched text stays in the first line
I populated the textview with attributed text with some attributes. What i tried is get the text before the target text and tried to get the size of it adding the exact attributes i added on textview text at the beginning.
made a CGPoint using that height and set the contentOffset of the uitextview. But the textview scrolled no where near the target text. Maybe my approach is wrong as i dont have the width of the textview when setting attributes
My code is :
func goToBla() {
if let string = ayatTextView.text {
if let range = string.range(of: tazweedAyahas[8].text) {
let firstPart = string[string.startIndex..<range.lowerBound]
print("before \(tazweedAyahas[5].text) : ")
print(firstPart)
if let otherStr = firstPart as? NSString {
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.alignment = .center
let size = otherStr.size(withAttributes: [NSAttributedStringKey.paragraphStyle: paragraphStyle,
NSAttributedStringKey.font: UIFont(name: "me_quran", size: 30)
])
let point = CGPoint(x: 0, y: size.height)
ayatTextView.setContentOffset(point, animated: true)
}
}
}
}
Your approach is much more complicated than it needs to be. Use the scrollRangeToVisible method of UITextView.
func goToBla() {
if let string = ayatTextView.text, let range = string.localizedStandardRange(of: tazweedAyahas[8].text) {
let viewRange = NSRange(range, in: string)
ayatTextView.selectedRange = viewRange // optional
ayatTextView.scrollRangeToVisible(viewRange)
}
}
Note the following:
Use localizedStandardRange when searching if you want to ignore diacritics and case, and you want other locale specific search features.
Call selectedRange if you want the matching text selected.
If you want to get the selected range at the top, try something like this instead of calling scrollRangeToVisible:
let rect = ayatTextView.layoutManager.boundingRect(forGlyphRange: viewRange, in: ayatTextView.textContainer)
ayatTextView.contentOffset = CGPoint(x: 0, y: rect.origin.y)

UILabel vertical alignment

In my application i am using ActiveLabelfram Github.
In that case, my label does not show the text in the middle of the UILabel. If i use a normal UILabel it works fine, but when settings it to a ActiveLabel, it gets like this.
(Image is taken in runtime)
I think this is the code to play with the alignment somehow:
/// add line break mode
private func addLineBreak(attrString: NSAttributedString) -> NSMutableAttributedString {
let mutAttrString = NSMutableAttributedString(attributedString: attrString)
var range = NSRange(location: 0, length: 0)
var attributes = mutAttrString.attributesAtIndex(0, effectiveRange: &range)
let paragraphStyle = attributes[NSParagraphStyleAttributeName] as? NSMutableParagraphStyle ?? NSMutableParagraphStyle()
paragraphStyle.lineBreakMode = NSLineBreakMode.ByWordWrapping
if let lineSpacing = lineSpacing {
paragraphStyle.lineSpacing = CGFloat(lineSpacing)
}
attributes[NSParagraphStyleAttributeName] = paragraphStyle
mutAttrString.setAttributes(attributes, range: range)
return mutAttrString
}
ActiveLabel.swift
ActiveType.swift
Any ideas how i can make it in the middle like this:
(Image is taken from Storyboard)
In ActiveLabel.swift replace the drawTextInRect method with
public override func drawTextInRect(rect: CGRect) {
let range = NSRange(location: 0, length: textStorage.length)
textContainer.size = rect.size
let usedRect = layoutManager.usedRectForTextContainer(textContainer)
let glyphOriginY = (rect.height > usedRect.height) ? rect.origin.y + (rect.height - usedRect.height) / 2 : rect.origin.y
let glyphOrigin = CGPointMake(rect.origin.x, glyphOriginY)
layoutManager.drawBackgroundForGlyphRange(range, atPoint: glyphOrigin)
layoutManager.drawGlyphsForGlyphRange(range, atPoint: glyphOrigin)
}
I have also forked the repo under https://github.com/rishi420/ActiveLabel.swift
If you download the repo, remember to set verticalTextAlignmentCenter to true
Have a look at this post:
Programmatically Add CenterX/CenterY Constraints
Well the problem will be when u have dragged a label already from the IB and then you are trying to change its position. The code will break.
You will need to programmatically make the label and then set it to the centre.
And very seriously, #Alex is correct. AutoLayout solves a lot of problems.
You can set the textAlignment in the code like this:
showLab.textAlignment = NSTextAlignmentCenter;
Or you can also use Storyboard or xib to see what happen in the lab,StoryBoard,as you look i choose the Second of alignment what means middle in the label

Resources