简体   繁体   中英

Size UITextView to fit multiline NSAttributedString

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
}

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM