IOS 18 uses TextKit to calculate the height of attributed strings, but the calculation is inaccurate.

In iOS 18, using TextKit to calculate the height of attributed strings is inaccurate. The same method produces correct results in systems below iOS 18.

- (void)viewDidLoad {
    [super viewDidLoad];
    // Do any additional setup after loading the view.
    
    UITextView *textView = [[UITextView alloc] initWithFrame:CGRectMake(20, 40, 100, 0)];
    textView.editable = NO;
    textView.scrollEnabled = NO;
    textView.textContainerInset = UIEdgeInsetsMake(0, 0, 0, 0);
    textView.textContainer.lineFragmentPadding = 0;
    textView.backgroundColor = [UIColor lightGrayColor];
    [self.view addSubview:textView];
    
    NSMutableAttributedString *attributedString = [[NSMutableAttributedString alloc] initWithString:@"陈家坝好吃的撒海程邦达不差大撒把传达是吧才打卡吃吧金卡多措并举哈不好吃大杯茶十八次是吧"];
    
    NSMutableParagraphStyle *paragraphStyle = [[NSMutableParagraphStyle alloc] init];
    paragraphStyle.lineSpacing = 4;
    [attributedString addAttribute:NSParagraphStyleAttributeName value:paragraphStyle range:NSMakeRange(0, attributedString.length)];
    
    [attributedString addAttribute:NSFontAttributeName value:[UIFont systemFontOfSize:16] range:NSMakeRange(0, attributedString.length)];
    
    [attributedString addAttribute:NSForegroundColorAttributeName value:[UIColor redColor] range:NSMakeRange(0, attributedString.length)];
    
    
    textView.attributedText = attributedString;
    
    CGFloat height = [self test:attributedString];
    
    textView.frame = CGRectMake(20, 40, 100, height);
    
}

- (CGFloat)test:(NSAttributedString *)attString {

    NSTextStorage *textStorage = [[NSTextStorage alloc] initWithAttributedString:attString];

    NSLayoutManager *layoutManager = [[NSLayoutManager alloc] init];
    [textStorage addLayoutManager:layoutManager];

    NSTextContainer *textContainer = [[NSTextContainer alloc] initWithSize:CGSizeMake(100, CGFLOAT_MAX)];
    textContainer.lineFragmentPadding = 0;

    [layoutManager addTextContainer:textContainer];

    [layoutManager ensureLayoutForTextContainer:textContainer];
    
    CGFloat height = [layoutManager usedRectForTextContainer:textContainer].size.height;

    return ceil(height);
}

Answered by DTS Engineer in 819108022

Putting aside the different behavior between iOS 17 and iOS 18, I'd say that the code you provided isn't quite right, in that you render the text with TextKit2 but measure it with TextKit1.

Concretely, UITextView by default uses TextKit2 in iOS 16 and later, and so textView created in your viewDidLoad is a TextKit2 text view. Unless you force textView to fall back to TextKit1 with code not shown here (by accessing its layoutManager property), it will use TextKit2 to render the text. And yet, your test: method uses NSLayoutManager, which is part of TextKit1, to measure the height of the text. It isn't a surprise that the height measured with TextKit1 is different from the height that TextKit2 needs to render the text.

You might consider using the same text engine, TextKit2 here, to measure and render the text. The result should be more matching. The following code example shows how to measure a piece of text with TextKit2:

func sizeOf(nsAttributedString: NSAttributedString, width: CGFloat) -> CGSize {
    let textContainer = NSTextContainer(size: CGSize(width: width, height: 0))
    textContainer.lineFragmentPadding = 0
    
    let textLayoutManager = NSTextLayoutManager()
    textLayoutManager.textContainer = textContainer
    
    let textContentStorage = NSTextContentStorage()
    textContentStorage.attributedString = nsAttributedString
    textContentStorage.addTextLayoutManager(textLayoutManager)

    textLayoutManager.ensureLayout(for: textLayoutManager.documentRange)
    
    let rect = textLayoutManager.usageBoundsForTextContainer
    return rect.size
}

Best,
——
Ziqiao Chen
 Worldwide Developer Relations.

Accepted Answer

Putting aside the different behavior between iOS 17 and iOS 18, I'd say that the code you provided isn't quite right, in that you render the text with TextKit2 but measure it with TextKit1.

Concretely, UITextView by default uses TextKit2 in iOS 16 and later, and so textView created in your viewDidLoad is a TextKit2 text view. Unless you force textView to fall back to TextKit1 with code not shown here (by accessing its layoutManager property), it will use TextKit2 to render the text. And yet, your test: method uses NSLayoutManager, which is part of TextKit1, to measure the height of the text. It isn't a surprise that the height measured with TextKit1 is different from the height that TextKit2 needs to render the text.

You might consider using the same text engine, TextKit2 here, to measure and render the text. The result should be more matching. The following code example shows how to measure a piece of text with TextKit2:

func sizeOf(nsAttributedString: NSAttributedString, width: CGFloat) -> CGSize {
    let textContainer = NSTextContainer(size: CGSize(width: width, height: 0))
    textContainer.lineFragmentPadding = 0
    
    let textLayoutManager = NSTextLayoutManager()
    textLayoutManager.textContainer = textContainer
    
    let textContentStorage = NSTextContentStorage()
    textContentStorage.attributedString = nsAttributedString
    textContentStorage.addTextLayoutManager(textLayoutManager)

    textLayoutManager.ensureLayout(for: textLayoutManager.documentRange)
    
    let rect = textLayoutManager.usageBoundsForTextContainer
    return rect.size
}

Best,
——
Ziqiao Chen
 Worldwide Developer Relations.

I'm a bit confused about the answer being marked as accepted. Does this mean there wasn't a discrepancy between iOS 18 and earlier versions? I'm asking because we're experiencing a bug where the UITextView does not always resize its height correctly when we add an attributed string. We had an old hack that involved disabling/enabling isScrollEnabled, but this stopped working with iOS 18. Might not be related at all, but this post is the closest I've come to something that could be.

IOS 18 uses TextKit to calculate the height of attributed strings, but the calculation is inaccurate.
 
 
Q