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:
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:


Instead of setting the line spacing through a
NSMutableParagraphStylewhat seems to work is usingNSLayoutManagerDelegate'slayoutManager(lineSpacingAfterGlyphAt…)I even set my
NSLayoutManagersubclass 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.