How to Highlight a Uitextview's Text Line by Line in Swift

How to highlight a UITextView's text line by line in swift?

So you want this:

demo

You need the text view's contents to always be the full string, with one line highlighted, but your code sets it to just the highlighted line. Your code also schedules all the highlights to happen at the same time (.now() + 0.5) instead of at different times.

Here's what I'd suggest:

  1. Create an array of ranges, one range per line.

  2. Use that array to modify the text view's textStorage by removing and adding the .backgroundColor attribute as needed to highlight and unhighlight lines.

  3. When you highlight line n, schedule the highlighting of line n+1. This has two advantages: it will be easier and more efficient to cancel the animation early if you need to, and it will be easier to make the animation repeat endlessly if you need to.

I created the demo above using this playground:

import UIKit
import PlaygroundSupport

let text = "This is\n some placeholder\n text\nwith newlines."
let textView = UITextView(frame: CGRect(x: 0, y:0, width: 200, height: 100))
textView.backgroundColor = .white
textView.text = text

let textStorage = textView.textStorage

// Use NSString here because textStorage expects the kind of ranges returned by NSString,
// not the kind of ranges returned by String.
let storageString = textStorage.string as NSString
var lineRanges = [NSRange]()
storageString.enumerateSubstrings(in: NSMakeRange(0, storageString.length), options: .byLines, using: { (_, lineRange, _, _) in
lineRanges.append(lineRange)
})

func setBackgroundColor(_ color: UIColor?, forLine line: Int) {
if let color = color {
textStorage.addAttribute(.backgroundColor, value: color, range: lineRanges[line])
} else {
textStorage.removeAttribute(.backgroundColor, range: lineRanges[line])
}
}

func scheduleHighlighting(ofLine line: Int) {
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1)) {
if line > 0 { setBackgroundColor(nil, forLine: line - 1) }
guard line < lineRanges.count else { return }
setBackgroundColor(.yellow, forLine: line)
scheduleHighlighting(ofLine: line + 1)
}
}

scheduleHighlighting(ofLine: 0)

PlaygroundPage.current.liveView = textView

Highlight UITextView text

You can enumerate (enumerate(_:in:option:)) on the NSAttributedString.Key.backgroundColor to find change only when it has a background.
Then, you can use a regex, or a while loop with range(of:) to find where they are, and remove the .backgroundColor on them:

With sample code on Playgrounds:

func highlights() -> UITextView {

let tv = UITextView(frame: CGRect(x: 0, y: 0, width: 300, height: 200))
tv.backgroundColor = .orange

let text = "Hello world! How are you today?\nLet's start do some testing.\nAnd this is a long paragraph just to see it to the end of the line."
let attributes: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 15.0),
.backgroundColor: UIColor.systemPink]

let first = NSAttributedString(string: text, attributes: attributes)
let second = NSMutableAttributedString(string: text, attributes: attributes)
guard let regex = try? NSRegularExpression(pattern: "\n", options: []) else { return tv }
second.enumerateAttribute(.backgroundColor, in: NSRange(location: 0, length: second.length), options: []) { attribute, range, stop in
guard attribute as? UIColor != nil else { return }
guard let subrange = Range(range, in: second.string) else { return }
let substring = String(second.string[subrange])
let ranges = regex.matches(in: substring, options: [], range: NSRange(location: 0, length: substring.utf16.count))
ranges.forEach {
second.removeAttribute(.backgroundColor, range: $0.range)
}
}
let total = NSMutableAttributedString()
total.append(first)
total.append(NSAttributedString(string: "\nNormal Text, nothing to see here\n"))
total.append(second)
total.append(NSAttributedString(string: "\nNormal Text, nothing to see here\n"))
tv.attributedText = total
return tv
}
let tv = highlights()

Sample Image

Side note:
I didn't handle the case if you have in the string "\n \n", that might need some changes in the regex pattern.
After a quick test, then NSRegularExpression(pattern: "\n(\\s+\n)*", options: []) might do the trick.

Using a CALayer to highlight text in a UITextView which spans multiple lines

I've found the solution:

Instead of using enumerateEnclosingRectsForGlyphRange: I use the NSString method enumerateSubstringsInRange:options:usingBlock:

I enumerate the line fragments as usual, but instead of trying to use the enclosing rect enumeration method, I enumerate each character on the line while building the rect for the layer.

-(void)drawLayerForTextHighlightWithString:(NSString*)string {

for (CALayer* eachLayer in [self highlightLayers]) {
[eachLayer removeFromSuperlayer];
}

NSLayoutManager* manager = [[self textView]layoutManager];

// Find the string
NSRange match = [[[self textView]text]rangeOfString:string options:
NSCaseInsensitiveSearch | NSDiacriticInsensitiveSearch | NSWidthInsensitiveSearch];

// Convert it to a glyph range
NSRange matchingGlyphRange = [manager glyphRangeForCharacterRange:match actualCharacterRange:NULL];

// Enumerate each line in that glyph range (this will fire for each line that the match spans)
[manager enumerateLineFragmentsForGlyphRange:matchingGlyphRange usingBlock:
^(CGRect lineRect, CGRect usedRect, NSTextContainer *textContainer, NSRange lineRange, BOOL *stop) {

// currentRange uses NSIntersectionRange to return the range of the text that is on the current line
NSRange currentRange = NSIntersectionRange(lineRange, matchingGlyphRange);

// This rect will be built by enumerating each character in the line, and adding to it's width
__block CGRect finalLineRect = CGRectZero;

// Here we use enumerateSubstringsInRange:... to go through each glyph and build the final rect for the line
[[[self textView]text]enumerateSubstringsInRange:currentRange options:NSStringEnumerationByComposedCharacterSequences usingBlock:
^(NSString* substring, NSRange substringRange, NSRange enclostingRange, BOOL* stop) {

// The range of the single glyph being enumerated
NSRange singleGlyphRange = [manager glyphRangeForCharacterRange:substringRange actualCharacterRange:NULL];

// get the rect for that glyph
CGRect glyphRect = [manager boundingRectForGlyphRange:singleGlyphRange inTextContainer:textContainer];

// check to see if this is the first iteration, if not add the width to the final rect for the line
if (CGRectEqualToRect(finalLineRect, CGRectZero)) {
finalLineRect = glyphRect;
} else {
finalLineRect.size.width += glyphRect.size.width;
}

}];

// once we get the rect for the line, draw the layer
UIEdgeInsets textContainerInset = [[self textView]textContainerInset];
finalLineRect.origin.x += textContainerInset.left;
finalLineRect.origin.y += textContainerInset.top;

CALayer* roundRect = [CALayer layer];
[roundRect setFrame:finalLineRect];
[roundRect setBounds:finalLineRect];

[roundRect setCornerRadius:5.0f];
[roundRect setBackgroundColor:[[UIColor blueColor]CGColor]];
[roundRect setOpacity:0.2f];
[roundRect setBorderColor:[[UIColor blackColor]CGColor]];
[roundRect setBorderWidth:3.0f];
[roundRect setShadowColor:[[UIColor blackColor]CGColor]];
[roundRect setShadowOffset:CGSizeMake(20.0f, 20.0f)];
[roundRect setShadowOpacity:1.0f];
[roundRect setShadowRadius:10.0f];

[[[self textView]layer]addSublayer:roundRect];
[[self highlightLayers]addObject:roundRect];

// continues for each line
}];

}

I'm still working on multiple matches, i'll update the code once I get that working.

How to add whitespace between highlighted lines of text?

Figured it out.

Seems like you have to use CoreText to pull it off though, not just TextKit.

I still have to figure out how to extend the highlights so they cover the bottoms of letters and not so much of the top. And I have to figure out how to move the highlights so they're "behind" the text and not making the font color lighter, but this will get you 90% of the way there.

Sample Image

import UIKit
import CoreText
import PlaygroundSupport

// Sources
// https://stackoverflow.com/questions/48482657/catextlayer-render-attributedstring-with-truncation-and-paragraph-style
// https://stackoverflow.com/a/52320276/1291940
// https://stackoverflow.com/a/55283002/1291940

// Create a view to display what's going on.
var demoView = UIView(frame: CGRect(x: 0, y: 0, width: 500, height: 500))
demoView.backgroundColor = UIColor.white // Haven't figured out if you can create a boundary around a UIView
PlaygroundPage.current.liveView = demoView // Apparently it doesn't matter where we place this code

// Calculates height of frame given a string of a certain length
extension String {
func sizeOfString(constrainedToWidth width: Double, font: UIFont) -> CGSize {
let attributes = [NSAttributedString.Key.font : font]
let attString = NSAttributedString(string: self, attributes: attributes)
let framesetter = CTFramesetterCreateWithAttributedString(attString)
return CTFramesetterSuggestFrameSizeWithConstraints(framesetter, CFRange(location: 0, length: 0), nil, CGSize(width: width, height: .greatestFiniteMagnitude), nil)
}
}

// Unwraps optional so our program doesn't crash in case the user doesn't have the specified font.
func unwrappedFont(fontSize: CGFloat) -> UIFont {

if let textFont = UIFont(name: "Futura", size: fontSize) {
return textFont
}
else {
return UIFont.systemFont(ofSize: fontSize)
}
}

let string = "When you hear or read someone weaving their ideas into a beautiful mosaic of words, try to remember, they are almost certainly wrong."
var dynamicHeight = string.sizeOfString(constrainedToWidth: 500, font: unwrappedFont(fontSize: 40)).height
// dynamicHeight = 500
let boxSize = CGSize(width: 500, height: dynamicHeight)
// let boxSize = CGSize(width: 500, height: 500)
var imageBounds : [CGRect] = [] // rectangle highlight
let renderer = UIGraphicsImageRenderer(size: boxSize)
let img = renderer.image { ctx in

// Flipping the coordinate system
ctx.cgContext.textMatrix = .identity
ctx.cgContext.translateBy(x: 0, y: boxSize.height) // Alternatively y can just be 500.
ctx.cgContext.scaleBy(x: 1.0, y: -1.0)

// Setting up constraints for quote frame
let range = NSRange( location: 0, length: string.count)
guard let context = UIGraphicsGetCurrentContext() else { return }
let path = CGMutablePath()
let bounds = CGRect(x: 0, y: 0, width: boxSize.width, height: boxSize.height)
path.addRect(bounds)
let attrString = NSMutableAttributedString(string: string)
attrString.addAttribute(NSAttributedString.Key.font, value: UIFont(name: "Futura", size: 40)!, range: range )
let framesetter = CTFramesetterCreateWithAttributedString(attrString as CFAttributedString)
let frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, attrString.length), path, nil)

CTFrameDraw(frame, context)

// Setting up variables for highlight creation
let lines = CTFrameGetLines(frame) as NSArray
var lineOriginsArray : [CGPoint] = []
var contextHighlightRect : CGRect = CGRect()
var counter = 0

// Draws a rectangle over each line.
for line in lines {
let ctLine = line as! CTLine
let numOfLines: size_t = CFArrayGetCount(lines)
lineOriginsArray = [CGPoint](repeating: CGPoint.zero, count: numOfLines)

CTFrameGetLineOrigins(frame, CFRangeMake(0,0), &lineOriginsArray)
imageBounds.append(CTLineGetImageBounds(ctLine, context))

// Draw highlights
contextHighlightRect = CGRect(x: lineOriginsArray[counter].x, y: lineOriginsArray[counter].y, width: imageBounds[counter].size.width, height: imageBounds[counter].size.height)
ctx.cgContext.setStrokeColor(red: 0, green: 0, blue: 0, alpha: 0.5)
ctx.cgContext.stroke(contextHighlightRect)
ctx.cgContext.setFillColor(red: 1, green: 1, blue: 0, alpha: 0.3)
ctx.cgContext.fill(contextHighlightRect)
counter = counter + 1
}
}

// Image layer
let imageLayer = CALayer()
imageLayer.contents = img.cgImage
imageLayer.position = CGPoint(x: 0, y: 0)
imageLayer.frame = CGRect(x: 0, y: 0, width: 500, height: dynamicHeight)

// Adding layers to view
demoView.layer.addSublayer(imageLayer)

How to highlight text in a textView in Swift?

If you only need to highlight a single, contiguous block of text you can set the selection programmatically. Your examples show multiple discontinuous parts however. For that you are going to need to use an attributed string. There is no other system-provided highlighting function. (There might be a third party library that offers this though - you'll have to search.)

UITextView highlight all matches using swift

Obligatory NSRegularExpression based solution.

let searchString = "this"
let baseString = "This is some string that contains the word \"this\" more than once. This substring has multiple cases. ThisthisThIs."

let attributed = NSMutableAttributedString(string: baseString)

var error: NSError?
let regex = NSRegularExpression(pattern: searchString, options: .CaseInsensitive, error: &error)

if let regexError = error {
println("Oh no! \(regexError)")
} else {
for match in regex?.matchesInString(baseString, options: NSMatchingOptions.allZeros, range: NSRange(location: 0, length: baseString.utf16Count)) as [NSTextCheckingResult] {
attributed.addAttribute(NSBackgroundColorAttributeName, value: UIColor.yellowColor(), range: match.range)
}

textView.attributedText = attributed
}

UITextView correctly highlight words

If I got it right, you have ranges of string that you want to be on the same line. If so, you can replace a regular space in these ranges with a no-break space like that

for range in backgroundRangeArray {
text = text?.replacingOccurrences(of: " ", with: "\u{00a0}", options: .caseInsensitive, range: range)
}


Related Topics



Leave a reply



Submit