Search code examples
iosswiftuitextviewnsattributedstringcore-text

How to add whitespace between highlighted lines of text?


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?


Solution

  • 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)