Search code examples
iosswiftmultithreadingtextviewnsattributedstring

Coloring text in UITextView with NSAttributedString is really slow


I'm making a simple code viewer / editor on top of a UITextView and so I want to color some of the keywords (vars, functions, etc...) so it's easy to view like in an IDE. I'm using NSAttributedString to do this and coloring in range using the functions apply(...) in a loop (see below). However, when there are a lot of words to color it starts becoming really slow and jamming the keyboard (not so much on the simulator but its really slow on an actual device). I thought I could use threading to solve this but when I run the apply function in DispatchQueue.global().async {...} it doesn't color anything at all. Usually if there's some UI call that needs to run in the main thread it will print out the error / crash and so I can find where to add DispatchQueue.main.sync {...} and I've tried in various places and it still doesnt work. Any suggestions on how I might resolve this?


Call update

func textViewDidChange(_ textView: UITextView) {
    updateLineText()
}

Update function

var wordToColor = [String:UIColor]()

func updateLineText() {

    var newText = NSMutableAttributedString(string: content)

    // some values are added to wordToColor here dynamically. This is quite fast and can be done asynchronously.

    // when this is run asynchronously it doesn't color at all...
    for word in wordToColor.keys {
        newText = apply(string: newText, word: word)
    }

    textView.attributedText = newText
}

Apply functions

func apply (string: NSMutableAttributedString, word: String) -> NSMutableAttributedString {
    let range = (string.string as NSString).range(of: word)
    return apply(string: string, word: word, range: range, last: range)
}

func apply (string: NSMutableAttributedString, word: String, range: NSRange, last: NSRange) -> NSMutableAttributedString {
    if range.location != NSNotFound {

        if (rangeCheck(range: range)) {
            string.addAttribute(NSAttributedStringKey.foregroundColor, value: wordToColor[word], range: range)
            if (range.lowerBound != 0) {
                let index0 = content.index(content.startIndex, offsetBy: range.lowerBound-1)
                if (content[index0] == ".") {
                    string.addAttribute(NSAttributedStringKey.foregroundColor, value: UIColor.purple, range: range)
                }
            }

        }

        let start = last.location + last.length
        let end = string.string.count - start
        let stringRange = NSRange(location: start, length: end)
        let newRange = (string.string as NSString).range(of: word, options: [], range: stringRange)
        apply(string: string, word: word, range: newRange, last: range)
    }
    return string
}

Solution

  • This will be more of some analysis and some suggestions rather than a full code implementation.

    Your current code completely rescans the all of the text and reapplies all of the attributes for each and every character the user types into the text view. Clearly this is very inefficient.

    One possible improvement would be to implement the shouldChangeTextInRange delegate. Then you can start with the existing attributed string and then process only the range being changed. You might need to process a bit of the text on either side but this would be much more efficient than reprocessing the whole thing.

    You could combine the two perhaps. If the current text is less than some appropriate size, do a full scan. Once it reaches a critical size, do the partial update.

    Another consideration is to do all scanning and creation of the attribute string in the background but make it interruptible. Each text update your cancel and current processing and start again. Don't actually update the text view with the newly calculated attributed text until the user stops typing long enough for your processing to complete.

    But I would make use of Instruments and profile the code. See what it taking the most time. Is it find the words? Is it creating the attributed string? Is it constantly setting the attributedText property of the text view?

    You might also consider going deeper into Core Text. Perhaps UITextView just isn't well suited to your task.