Thanks for being a part of WWDC25!

How did we do? We’d love to know your thoughts on this year’s conference. Take the survey here

NSTextView doesn't correctly redraw when deleting text and setting attribute at the same time

It seems that NSTextView has an issue with deleting text and setting any attribute at the same time, when it also has a textContainerInset.

With the code below, after 1 second, the empty line in the text view is automatically deleted and the first line is colored red. The top part of the last line remains visible at its old position. Selecting the whole text and then deselecting it again makes the issue disappear.

Is there a workaround?

I've created FB16897003.

class ViewController: NSViewController {

    @IBOutlet var textView: NSTextView!
    
    override func viewDidAppear() {
        textView.textContainerInset = CGSize(width: 0, height: 8)
        let _ = textView.layoutManager
        textView.textStorage!.setAttributedString(NSAttributedString(string: "1\n\n2\n3\n4"))
        textView.textStorage!.addAttribute(.foregroundColor, value: NSColor.labelColor, range: NSRange(location: 0, length: textView.textStorage!.length))
        
        DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [self] in
            textView.selectedRange = NSRange(location: 3, length: 0)
            textView.deleteBackward(nil)
            textView.textStorage!.beginEditing()
            textView.textStorage!.addAttribute(.foregroundColor, value: NSColor.red, range: NSRange(location: 0, length: 2))
            textView.textStorage!.endEditing()
        }
    }

}
Answered by DTS Engineer in 831004022

You are using TextKit1 (by accessing textView.layoutManager). If you can switch to TextKit2, as shown in the following code, the issue will disappear.

guard let textContextManager = textView.textLayoutManager?.textContentManager else {
    print("`textView.textLayoutManager?.textContentManager` is nil. You are still on TextKit1?")
    return
}
textView.textContainerInset = CGSize(width: 0, height: 8)
//let _ = textView.layoutManager
textContextManager.performEditingTransaction {
    textView.textStorage!.setAttributedString(NSAttributedString(string: "1\n\n2\n3\n4"))
    textView.textStorage!.addAttribute(.foregroundColor, value: NSColor.labelColor, range: NSRange(location: 0, length: textView.textStorage!.length))
    textView.textStorage!.addAttribute(.paragraphStyle, value: paragraphStyle(indent: 100), range: NSRange(location: 0, length: 2))
    textView.textStorage!.addAttribute(.paragraphStyle, value: paragraphStyle(indent: 100), range: NSRange(location: 1, length: 2))
    textView.textStorage!.addAttribute(.paragraphStyle, value: paragraphStyle(indent: 100), range: NSRange(location: 3, length: 2))
    textView.textStorage!.addAttribute(.paragraphStyle, value: paragraphStyle(indent: 100), range: NSRange(location: 5, length: 2))
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [self] in
    textContextManager.performEditingTransaction {
        textView.selectedRange = NSRange(location: 3, length: 0)
        textView.deleteBackward(nil)
        //textView.textStorage!.beginEditing()
        textView.textStorage!.addAttribute(.paragraphStyle, value: paragraphStyle(indent: 100), range: NSRange(location: 0, length: 2))
        textView.textStorage!.addAttribute(.paragraphStyle, value: paragraphStyle(indent: 100), range: NSRange(location: 2, length: 2))
        textView.textStorage!.addAttribute(.paragraphStyle, value: paragraphStyle(indent: 100), range: NSRange(location: 4, length: 2))
        textView.textStorage!.addAttribute(.paragraphStyle, value: paragraphStyle(indent: 100), range: NSRange(location: 6, length: 1))
        //textView.textStorage!.endEditing()
    }
}

If you need to stick with TextKit1, in which case I'd be curious of why, commenting out the following line seems to work around the issue:

//textView.textContainerInset = CGSize(width: 0, height: 8)

If the intent of using a custom textContainerInset is to move the text down 8 points, you might be able to achieve it by moving textView down.

Best,
——
Ziqiao Chen
 Worldwide Developer Relations.

For some reason the code above doesn't reproduce the issue anymore. Here is new code that seems to work (for now):

class ViewController: NSViewController {

    @IBOutlet var textView: NSTextView!
    
    override func viewDidAppear() {
        textView.textContainerInset = CGSize(width: 0, height: 8)
        let _ = textView.layoutManager
        textView.textStorage!.setAttributedString(NSAttributedString(string: "1\n\n2\n3\n4"))
        textView.textStorage!.addAttribute(.foregroundColor, value: NSColor.labelColor, range: NSRange(location: 0, length: textView.textStorage!.length))
        textView.textStorage!.addAttribute(.paragraphStyle, value: paragraphStyle(indent: 100), range: NSRange(location: 0, length: 2))
        textView.textStorage!.addAttribute(.paragraphStyle, value: paragraphStyle(indent: 100), range: NSRange(location: 1, length: 2))
        textView.textStorage!.addAttribute(.paragraphStyle, value: paragraphStyle(indent: 100), range: NSRange(location: 3, length: 2))
        textView.textStorage!.addAttribute(.paragraphStyle, value: paragraphStyle(indent: 100), range: NSRange(location: 5, length: 2))

        DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [self] in
            textView.selectedRange = NSRange(location: 3, length: 0)
            textView.deleteBackward(nil)
            textView.textStorage!.beginEditing()
            textView.textStorage!.addAttribute(.paragraphStyle, value: paragraphStyle(indent: 100), range: NSRange(location: 0, length: 2))
            textView.textStorage!.addAttribute(.paragraphStyle, value: paragraphStyle(indent: 100), range: NSRange(location: 2, length: 2))
            textView.textStorage!.addAttribute(.paragraphStyle, value: paragraphStyle(indent: 100), range: NSRange(location: 4, length: 2))
            textView.textStorage!.addAttribute(.paragraphStyle, value: paragraphStyle(indent: 100), range: NSRange(location: 6, length: 1))
            textView.textStorage!.endEditing()
        }
    }
    
    private func paragraphStyle(indent: Double) -> NSParagraphStyle {
        let paragraphStyle = NSMutableParagraphStyle()
        paragraphStyle.firstLineHeadIndent = indent
        return paragraphStyle
    }

}

You are using TextKit1 (by accessing textView.layoutManager). If you can switch to TextKit2, as shown in the following code, the issue will disappear.

guard let textContextManager = textView.textLayoutManager?.textContentManager else {
    print("`textView.textLayoutManager?.textContentManager` is nil. You are still on TextKit1?")
    return
}
textView.textContainerInset = CGSize(width: 0, height: 8)
//let _ = textView.layoutManager
textContextManager.performEditingTransaction {
    textView.textStorage!.setAttributedString(NSAttributedString(string: "1\n\n2\n3\n4"))
    textView.textStorage!.addAttribute(.foregroundColor, value: NSColor.labelColor, range: NSRange(location: 0, length: textView.textStorage!.length))
    textView.textStorage!.addAttribute(.paragraphStyle, value: paragraphStyle(indent: 100), range: NSRange(location: 0, length: 2))
    textView.textStorage!.addAttribute(.paragraphStyle, value: paragraphStyle(indent: 100), range: NSRange(location: 1, length: 2))
    textView.textStorage!.addAttribute(.paragraphStyle, value: paragraphStyle(indent: 100), range: NSRange(location: 3, length: 2))
    textView.textStorage!.addAttribute(.paragraphStyle, value: paragraphStyle(indent: 100), range: NSRange(location: 5, length: 2))
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [self] in
    textContextManager.performEditingTransaction {
        textView.selectedRange = NSRange(location: 3, length: 0)
        textView.deleteBackward(nil)
        //textView.textStorage!.beginEditing()
        textView.textStorage!.addAttribute(.paragraphStyle, value: paragraphStyle(indent: 100), range: NSRange(location: 0, length: 2))
        textView.textStorage!.addAttribute(.paragraphStyle, value: paragraphStyle(indent: 100), range: NSRange(location: 2, length: 2))
        textView.textStorage!.addAttribute(.paragraphStyle, value: paragraphStyle(indent: 100), range: NSRange(location: 4, length: 2))
        textView.textStorage!.addAttribute(.paragraphStyle, value: paragraphStyle(indent: 100), range: NSRange(location: 6, length: 1))
        //textView.textStorage!.endEditing()
    }
}

If you need to stick with TextKit1, in which case I'd be curious of why, commenting out the following line seems to work around the issue:

//textView.textContainerInset = CGSize(width: 0, height: 8)

If the intent of using a custom textContainerInset is to move the text down 8 points, you might be able to achieve it by moving textView down.

Best,
——
Ziqiao Chen
 Worldwide Developer Relations.

I'm still using TextKit1 for backwards compatibility.

If the intent of using a custom textContainerInset is to move the text down 8 points, you might be able to achieve it by moving textView down.

Yes, removing the container inset solves the issue, but I need it. Do you mean moving the scrollView down? That looks ugly. If you mean the textView inside the scrollView, how would I do that?

Did you try to insert an empty view above the text view as a sibling view (or a subview of the enclosing scrollview)? I think that should push the text view down.

Best,
——
Ziqiao Chen
 Worldwide Developer Relations.

I tried different ways, but wasn't able to find one that would still allow the text view to resize automatically according to its content. Do you have any suggestion?

NSTextView doesn't correctly redraw when deleting text and setting attribute at the same time
 
 
Q