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?

Dante
  • 115
  • 1
  • 6
  • Someone had the same issue in https://stackoverflow.com/questions/51186667/uitextview-background-colours-linespacing-area-too, but no answer either. – regina_fallangi Mar 10 '19 at 20:14
  • Also https://stackoverflow.com/questions/18939025/nsattributedstring-highlight-background-color-shows-between-lines-ugly?rq=1 – Sulthan Mar 10 '19 at 20:19

3 Answers3

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)
Dante
  • 115
  • 1
  • 6
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)
machoasif
  • 1
  • 1
-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;
    }
}
Red Heart
  • 41
  • 1
  • 11