Size UITextView to fit multiline NSAttributedString - ios

I have a UITextView containing an NSAttributedString. I want to size the text view so that, given a fixed width, it shows the entire string without scrolling.
NSAttributedString has a method which allows to compute its bounding rect for a given size
let computedSize = attributedString.boundingRect(with: CGSize(width: 200, height: CGFloat.greatestFiniteMagnitude),
options: .usesLineFragmentOrigin,
context: nil)
But unfortunately it seems not working, since it always returns the height of a single line.

After several attempts, I figured out that the NSAttributedString I was setting had byTruncatingTail as lineBreakMode value for NSParagraphStyle (which is the default value we use in our application).
To achieve the desired behaviour I have to change it to byWordWrapping or byCharWrapping.
let paragraphStyle = NSMutableParagraphStyle()
// When setting "byTruncatingTail" it returns a single line height
// paragraphStyle.lineBreakMode = .byTruncatingTail
paragraphStyle.lineBreakMode = .byWordWrapping
let stringAttributes: [NSAttributedString.Key: Any] = [.font: UIFont(name: "Avenir-Book", size: 16.0)!,
.paragraphStyle: paragraphStyle]
let attributedString = NSAttributedString(string: string,
attributes: stringAttributes)
let computedSize = attributedString.boundingRect(with: CGSize(width: 200, height: CGFloat.greatestFiniteMagnitude),
options: .usesLineFragmentOrigin,
context: nil)
computedSize.height
Note that when setting the attributed string with byTruncatingTail value on a UILabel (where numberOfLines value is 0), the string is "automatically" sized to be multiline, which doesn't happen when computing the boundingRect.
There are other factors to keep in mind when computing NSAttributedString height for use inside a UITextView (each one of these can cause the string not to be entirely contained in the text view):
1. Recompute height when bounds change
Since height is based on bounds, it should be recomputed when bounds change. This can be achieved using KVO on bounds keypath, invalidating the layout when this change.
observe(\.bounds) { (_, _) in
invalidateIntrinsicContentSize()
layoutIfNeeded()
}
In my case I'm invalidating intrinsicContentSize of my custom UITextView since is the way I size it based on the computed string height.
2. Use NSTextContainer width
Use textContainer.width (instead of bounds.width) as the fixed width to use for boundingRect method call, since it keeps any textContainerInset value into account (although left and right default values are 0)
3. Add vertical textContainerInsets values to string height
After computing NSAttributedString height we should add textContainerInsets.top and textContainerInsets.bottom to compute the correct UITextField height (their default values is 8.0...)
override var intrinsicContentSize: CGSize {
let computedHeight = attributedText.boundingHeight(forFixedWidth: textContainer.size.width)
return CGSize(width: bounds.width,
height: computedHeight + textContainerInset.top + textContainerInset.bottom)
}
4. Remove lineFragmentPadding
Set 0 as value of lineFragmentPadding or, if you want to have it, remember to remove its value from the "fixed width" before computing NSAttributedString height
textView.textContainer.lineFragmentPadding = 0
5. Apply ceil to computed height
The height value returned by boundingRect can be fractional, if we use as it is it can potentially cause the last line not to be shown. Pass it to the ceil function to obtain the upper integer value, to avoid down rounding.

A possible way to do it, is to subclass UITextView to inform you whenever its contentSize did change (~ the size of the text).
class MyExpandableTextView: UITextView {
var onDidChangeContentSize: ((CGSize) -> Void)?
override var contentSize: CGSize {
didSet {
onDidChangeContentSize?(contentSize)
}
}
}
On the "Parent View":
#IBOulet var expandableTextView: MyExpandableTextView! //Do not forget to set the class in the Xib/Storyboard
// or
var expandableTextView = MyExpandableTextView()
And applying the effect:
expandableTextView. onDidChangeContentSize = { [weak self] newSize in
// if you have a NSLayoutConstraint on the height:
// self?.myExpandableTextViewHeightConstraint.constant = newSize.height
// else if you play with "frames"
// self?.expandableTextView.frame.height = newSize.height
}

Related

UITextView does not adjust size when used in SwiftUI

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
}

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.

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

UITextView dynamic sizing is making the textView too tall

I am using this link to resize a textView dynamically. However, I implement it into a function and it return a height of 238. Using the heirarchy viewer I can see my textView is indeed 238, but there is a ton of empty space as 238 is way taller than needed. Can anyone figure out why textView.sizeThatFits: would give me this kind of error? Here is my code.
func heightOfTextFieldForMessage(message : String) -> CGFloat {
let horizontalMargin = CGFloat(194)
let textView = UITextView()
textView.text = message
textView.font = UIFont.systemFontOfSize(14)
//This width is same as in heirachy viewer, horizontalMargin is a constant distance the textView is from edges of the screen.
let fixedWidth = self.view.frame.width - horizontalMargin
let newSize = textView.sizeThatFits(CGSize(width: fixedWidth, height: CGFloat.max))
let height = newSize.height
print(height)
return height
}
Is it possibly because my font is smaller than the default font? I tried changing the textView's font size before resizing but it seems like like the height does change based on what I set the font to.
The Text View bottom constraint is tied to the top of the icon so as you can see the textView is way taller than needed.

My return of height of a UILabel is never accurate

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.

Resources