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