1

I've been trying to create an image that highlights text with whitespace between lines in a UITextView, like so:Karl Popper Quote

However, when I try to doing it in Swift, I find that using NSAttributedString.Key.backgroundColor to highlight the text and NSMutableParagraphStyle().lineSpacing to increase the spacing in UITextView between the lines simply expands the highlight, like so:

App Image

Is there any way I can control the height of the .backgroundColor so that it doesn't completely cover the whitespace between lines?

Or will I have to create each rectangle and overlay it on top of the text to get the result I want?

2

3 Answers 3

0

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.

enter image description here

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) 
Sign up to request clarification or add additional context in comments.

Comments

0

The paragraph needs to be highlighted when user taps on it. this is how I implemented it and don't confuse with the highlight color, it is a custom NSAttributedString key I created for this purpose.

extension NSAttributedString.Key { public static let highlightColor = NSAttributedString.Key.init("highlightColor") } class ReaderLayoutManager: NSLayoutManager { // MARK: - Draw Background override func drawBackground(forGlyphRange glyphsToShow: NSRange, at origin: CGPoint) { super.drawBackground(forGlyphRange: glyphsToShow, at: origin) self.enumerateLineFragments(forGlyphRange: glyphsToShow) { (_, usedRect, _, range, _) in guard let highlightColor = self.currentHighlightColor(range: range) else { return } guard let context = UIGraphicsGetCurrentContext() else { return } var lineRect = usedRect lineRect.origin.y += 10 lineRect.size.height -= 2 context.saveGState() let path = UIBezierPath(roundedRect: lineRect, cornerRadius: 2) highlightColor.setFill() path.fill() context.restoreGState() } } private func currentHighlightColor(range: NSRange) -> UIColor? { guard let textStorage = textStorage else { return nil } guard let highlightColor = textStorage.attributes(at: range.location, effectiveRange: nil)[.highlightColor] as? UIColor else { return nil } return highlightColor } } 

when user clicks on it, I set the highlight color for the range and reset the TextView.

attributedString.addAttributes([.highlightColor: theme.textUnderlineColor], range: range) 

Comments

-1

That code is solving my problem.

- (void)viewDidLoad { [super viewDidLoad]; UIMenuItem *highlightMenuItem = [[UIMenuItem alloc] initWithTitle:@"Highlight" action:@selector(highlight)]; [[UIMenuController sharedMenuController] setMenuItems:[NSArray arrayWithObject:highlightMenuItem]]; float sysVer = [[[UIDevice currentDevice] systemVersion] floatValue]; if (sysVer >= 8.0) { self.textView.layoutManager.allowsNonContiguousLayout = NO; } } - (void)highlight { NSRange selectedTextRange = self.textView.selectedRange; [attributedString addAttribute:NSBackgroundColorAttributeName value:[UIColor redColor] range:selectedTextRange]; float sysVer = [[[UIDevice currentDevice] systemVersion] floatValue]; if (sysVer < 8.0) { // iOS 7 fix self.textView.scrollEnabled = NO; self.textView.attributedText = attributedString; self.textView.scrollEnabled = YES; } else { self.textView.attributedText = attributedString; } } 

Comments

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.