Anters Bear
Anters Bear

Reputation: 1956

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
}

Upvotes: 2

Views: 607

Answers (2)

Karthick Ramesh
Karthick Ramesh

Reputation: 1486

I have a logger functionality in which i log all the service calls that i have made and can search for a particular string in that logs. I display the text in the textfield and highlight a text when searched for. I use below func with Regex and its not slow. Hope it helps you.

    func searchText(searchString: String) {
    guard let baseString = loggerTextView.text else {
        return
    }
    let attributed = NSMutableAttributedString(string: baseString)
    do {
        let regex = try! NSRegularExpression(pattern: searchString,options: .caseInsensitive)
        for match in regex.matches(in: baseString, options: NSRegularExpression.MatchingOptions(), range: NSRange(location: 0, length: baseString.count)) as [NSTextCheckingResult] {
            attributed.addAttribute(NSBackgroundColorAttributeName, value: UIColor.yellow, range: match.range)
        }
        attributed.addAttribute(NSFontAttributeName, value: UIFont.regularFont(ofSize: 14.0), range: NSRange(location: 0, length: attributed.string.count))
        self.loggerTextView.attributedText = attributed
    }
}

Upvotes: 1

rmaddy
rmaddy

Reputation: 318824

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.

Upvotes: 3

Related Questions