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.
Related
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
}
I have a UITextField for which I've set autoAdjustFontSizeToFitWidth to true and minimumFontSize to 0. The problem is the setting shrinks the text noticeably sooner than it really should. For example, here is an image of a UITextField with the above settings:
The green is the background color of the UITextField. In this example, the text has not shrunk yet, but no matter what I type as the next character the text field always begins shrinking; despite clearly being enough room on the left side for a few more characters. Here is another image with additional characters entered:
As you can see, there is a relatively large area on the left side that the text field won't place text in when auto adjusting. This is for a right aligned text field. The same can be said of center aligned text fields as well, where there is space on the left and right that seems as if an auto adjusting text field won't place text inside.
How do I get it so that auto adjusting text fields use the entire available space?
Update:
You can do the text calculations and font resizing manually. By doing so you will avoid hacks and future compatibility issues.
A simple implementation looks like this:
import UIKit
class ViewController: UIViewController {
#IBOutlet weak var textField: UITextField!
var originalFont: UIFont!
override func viewDidLoad() {
super.viewDidLoad()
self.textField.font = self.textField.font?.withSize(44)
self.textField.adjustsFontSizeToFitWidth = false
self.originalFont = textField.font
self.textField.addTarget(self, action: #selector(textFieldDidChange), for: .editingChanged)
}
#objc
func textFieldDidChange(sender: UITextField) {
let textRect = sender.textRect(forBounds: sender.bounds)
let textWidth = textRect.width
var currentFont = self.originalFont!
var i = 0
while (i < 10) {
let unrestrictedTextWidth = sender.text!.boundingRect(with: CGSize(width: .greatestFiniteMagnitude,
height: textRect.height),
attributes: [.font : currentFont],
context: nil).width
if unrestrictedTextWidth <= textWidth {
break
}
let factor = textWidth / max(textWidth, unrestrictedTextWidth)
let originalSize = currentFont.pointSize
currentFont = self.originalFont!.withSize(originalSize * factor)
i += 1
}
sender.font = currentFont
}
}
Interestingly the actual relationship between text rect and font size is non-linear and non-trivial. So I added multiple iteration steps to approximate the correct size. I chose a maximum of 10 iterations to avoid infinite loops on very small sizes and rounding errors.
Original Answer:
There has always been some magic around UITextField and adjustsFontSizeToFitWidth. See for example this post from 2015 about how the initial font size affects the minimum font size:
https://stackoverflow.com/a/30881385/921573
A UITextField with:
Font size 17, minimum size 15 will go down to 15 if need be
Font size 17, minimum size 10 will only go down to 14
Font size 13, minimum size 4 will stay at 13
In my tests, setting the minimum font size in IB to 0 just gets ignored – in order so see the shrinking effect it has to be a small value like 1.
Setting it in code to 0 works fine.
So I think it is safe to say that UITextField might be considered historically buggy when it comes to adjustsFontSizeToFitWidth.
That being said, I found a workaround for you:
class FixedTextField: UITextField {
override func textRect(forBounds bounds: CGRect) -> CGRect {
let magicNumber = -15.0
if self.adjustsFontSizeToFitWidth {
return CGRect(
x: bounds.origin.x + magicNumber,
y: bounds.origin.y,
width: bounds.size.width - magicNumber,
height: bounds.size.height
)
} else {
return super.textRect(forBounds: bounds)
}
}
override func editingRect(forBounds bounds: CGRect) -> CGRect {
return self.textRect(forBounds: bounds)
}
}
This custom text field uses countermagic to mitigate the issue.
You may have to play with the magicNumber according to your font or dimensions or device. For me 15 works ok:
This works for me and the textField.textAlignment is set to .right (it will depend on how many characters you put in the textField though) :
class TextFieldOne: UITextField {
override func alignmentRect(forFrame frame: CGRect) -> CGRect {
// let newWidth = frame.width + 10 // if you want to reduce the right side too.
let x = frame.origin.x - 15 // suit yourself here
let newFrame = CGRect(x: x, y: frame.origin.y, width: frame.width, height: frame.height)
return newFrame
}
override func editingRect(forBounds bounds: CGRect) -> CGRect {
return self.alignmentRect(forFrame: self.bounds)
}
override func textRect(forBounds bounds: CGRect) -> CGRect {
return self.alignmentRect(forFrame: self.bounds)
}
}
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
I'm trying to capture the height of my UILabel so that I can dynamically set the height of it's cell, but it never returns the right height, and effectually, my content is always truncated and appended with ellipses.
My code :
let height = String(page.valueForKey(subject)).heightWithConstrainedWidth(self.view.frame.width - 30, font: UIFont.systemFontOfSize(16.0))
// and at the bottom of my class..
// Custom functions
extension String {
func heightWithConstrainedWidth(width: CGFloat, font: UIFont) -> CGFloat {
let constraintRect = CGSize(width: width, height: CGFloat.max)
let boundingBox = self.boundingRectWithSize(constraintRect, options: .UsesLineFragmentOrigin, attributes: [NSFontAttributeName: font], context: nil)
return boundingBox.height
}
}
It definitely makes a valiant attempt because it returns some sort of dynamic number that is relatively large, but the number is always short and never accurate.
Is there something glaringly inapropriate about how I'm extracting height? Is there a better way to perform this?
Per Matt's response, I updated to measure the label as so :
// Custom functions
extension String {
func heightWithConstrainedWidth(width: CGFloat, font: UIFont) -> CGFloat {
let label:UILabel = UILabel(frame: CGRectMake(0,0,width, CGFloat.max))
label.numberOfLines = 0
label.lineBreakMode = .ByWordWrapping
label.font = font
label.text = self
label.sizeToFit()
return label.frame.height
}
}
But it's still not quite big enough..
Your code is fine for what it does. The problem is merely that a label is not a string. You are finding the height of a string. That isn't what you want to do. You want to find the height of a label! There is more to a UILabel, after all, than just the string it contains. The UILabel is taller than the string. You are not taking that into account.
I ran into an issue where I needed to animate translating a label vertically the same distance of a textField's text height. In most cases just textField.bounds.heigt but if the textField's height is bigger than the text height it will not be any good for me. So I need to know:
How to calculate the line height of the string text from its UIFont?
Regarding the duplicate:
There's a little bit different of what I need. that answer(which I've referenced in my answer) get the total height depending on 1) the string 2) the width 3) the font. What I needed is one line height dpending only on the font.
UIFont has a property lineHeight:
if let font = _textView.font {
let height = font.lineHeight
}
where font is your font
I have been searching for a way to do that and find this answer where it has a String extension to calculate the size for the string and a given font. I have modified it to do what I want (get the line height of text written using a font.):
extension UIFont {
func calculateHeight(text: String, width: CGFloat) -> CGFloat {
let constraintRect = CGSize(width: width, height: CGFloat.greatestFiniteMagnitude)
let boundingBox = text.boundingRect(with: constraintRect,
options: NSStringDrawingOptions.usesLineFragmentOrigin,
attributes: [NSAttributedStringKey.font: self],
context: nil)
return boundingBox.height
}
}
I hope this helps someone looking for it. (may be myself in the future).
I've used this String extension in the past to draw some text as opposed to creating a UILabel somewhere. I don't like the fact that I can't seem to get the real height of the specific text I want to draw (not every string contains capital letters or characters with descenders, etc.) I've used a couple of enums for horizontal and vertical alignment around the given point. Open to ideas on the vertical height.
public func draw(at pt: CGPoint,
font: UIFont? = UIFont.systemFont(ofSize: 12),
color: UIColor? = .black,
align: HorizontalAlignment? = .Center,
vAlign: VerticalAlignment? = .Middle)
{
let attributes: [NSAttributedString.Key : Any] = [.font: font!,
.foregroundColor: color!]
let size = self.boundingRect(with: CGSize(width: 0, height: 0),
options: [ .usesFontLeading ],
attributes: [ .font: font! ],
context: nil).size
var x = pt.x
var y = pt.y
if align == .Center {
x -= (size.width / 2)
} else if align == .Right {
x -= size.width
}
if vAlign == .Middle {
y -= (size.height / 2)
} else if vAlign == .Bottom {
y -= size.height
}
let rect = CGRect(x: x, y: y, width: size.width, height: size.height)
draw(in: rect, withAttributes: attributes)
}