swiftuitextviewnsattributedstringunderlinenslayoutmanager

How can I adjust the vertical position of a custom underline with UITextView and NSLayoutManager with non-zero lineSpacing NSParagraphStyle?


I want to customize the underline style for NSSAttributedString because patternDot is actually just short dashes, and I want actually circular dots (e.g. see Customize underline pattern in NSAttributedString (iOS7+)).

I got pretty far but I'm having an inconsistent alignment issue with the resulting underline, apparently because of custom line spacing. The underline is too far down if it's not on the last line of text but it's too far up if it's on the last line of text. Presumably it's because the last line doesn't have any line spacing underneath it. I suppose if there was a way, inside my drawUnderline to figure out what line of text I was on, I could adjust the y offset accordingly, but is there a simpler way?

Example:

example underline

class DotUnderlineLayoutManager: NSLayoutManager {
  let color: UIColor

  init(color: UIColor = .black) {
    self.swatch = swatch
    super.init()
  }

  override func drawUnderline(forGlyphRange glyphRange: NSRange, underlineType _: NSUnderlineStyle, baselineOffset: CGFloat, lineFragmentRect: CGRect, lineFragmentGlyphRange _: NSRange, containerOrigin: CGPoint) {
    guard let container = textContainer(
      forGlyphAt: glyphRange.location,
      effectiveRange: nil
    ) else { return }

    let rect = boundingRect(forGlyphRange: glyphRange, in: container)

    let offsetRect = rect.offsetBy(
      dx: containerOrigin.x,
      dy: containerOrigin.y // + baselineOffset <- adding this helps a bit with an underline on the last line but messes up other lines even more
    )

    let path = UIBezierPath()
    path.strokeDottedLine(under: offsetRect, color: color)    
  }
}

private extension UIBezierPath {
  func strokeDottedLine(under rect: CGRect, color: UIColor) {
    lineWidth = 2
    lineCapStyle = .round
    setLineDash([0.1, 5], count: 2, phase: 0)
    move(to: .init(x: rect.minX, y: rect.maxY))
    addLine(to: .init(x: rect.maxX, y: rect.maxY))
    color.setStroke()
    stroke()
  }
}



extension NSUnderlineStyle {
  static var patternCircularDot: NSUnderlineStyle {
    NSUnderlineStyle(rawValue: 0x11)
  }
}
let textView: UITextView = {
    let layout = DotUnderlineLayoutManager(swatch: swatch)
    let storage = NSTextStorage()
    storage.addLayoutManager(layout)
    let initialSize = CGSize(width: 0, height: CGFloat.greatestFiniteMagnitude)
    let container = NSTextContainer(size: initialSize)
    container.widthTracksTextView = true
    layout.addTextContainer(container)

    let textView = UITextView(frame: .zero, textContainer: container)
    textView.isUserInteractionEnabled = false
    textView.isEditable = false
    textView.isScrollEnabled = false
    textView.backgroundColor = .clear
    return textView
}()

let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.lineBreakMode = .byWordWrapping
paragraphStyle.lineSpacing = 10 // any non-zero value

textView.attributedText = NSAttributedString(
    string: myText,
    attributes: [
        .paragraphStyle: paragraphStyle
        .underlineStyle: NSUnderlineStyle.patternCircularDot.union(.single).rawValue
    ]
)

EDIT: the custom font doesn't actually matter, just the line spacing on the paragraph style, have simplified the question accordingly:

system font


Solution

  • Instead of setting the line spacing through a NSMutableParagraphStyle what seems to work is using NSLayoutManagerDelegate's layoutManager(lineSpacingAfterGlyphAt…)

    func layoutManager(_ layoutManager: NSLayoutManager, lineSpacingAfterGlyphAt glyphIndex: Int, withProposedLineFragmentRect rect: CGRect) -> CGFloat {
        // set line spacing as needed here
    }
    

    I even set my NSLayoutManager subclass to be its own delegate. Crazy, I know.

    Then I adjusted my rect offset to subtract half the line spacing, which seems to work well.