Customize underline pattern in NSAttributedString (iOS7 +)
I am trying to add a dotted underline style to NSAttributedString
(on iOS7 + / TextKit). Of course, I tried the built-in NSUnderlinePatternDot
:
NSString *string = self.label.text;
NSRange underlineRange = [string rangeOfString:@"consetetur sadipscing elitr"];
NSMutableAttributedString *attString = [[NSMutableAttributedString alloc] initWithString:string];
[attString addAttributes:@{NSUnderlineStyleAttributeName: @(NSUnderlineStyleSingle | NSUnderlinePatternDot)} range:underlineRange];
self.label.attributedText = attString;
However, the style created by this is actually quite broken than the dotted line:
Am I missing something obvious here ( NSUnderlinePatternReallyDotted
?;)) Or is there a way to customize the dot-pattern?
source to share
I spent a little time playing around with the Text Kit to get this and other similar scripts to work. The actual Text Kit solution for this problem is to subclass NSLayoutManager
and overridedrawUnderline(forGlyphRange:underlineType:baselineOffset:lineFragmentRect:lineFragmentGlyphRange:containerOrigin:)
This is the method that is called to actually draw the underline requested by the attributes in NSTextStorage
. Here's a description of the parameters that are passed to:
-
glyphRange
the index of the first glyph and the number of next glyphs to be underlined -
underlineType
NSUnderlineStyle
attribute valueNSUnderlineAttributeName
-
baselineOffset
the distance between the bottom border of the box and the baseline offset for glyphs in the provided range -
lineFragmentRect
a rectangle that spans the entire line containingglyphRange
-
lineFragmentGlyphRange
a range of glyphs that makes up the entire line containingglyphRange
-
containerOrigin
the offset of the container in the text view to which it belongs.
Steps to underline:
- Find
NSTextContainer
which containsglyphRange
usingNSLayoutManager.textContainer(forGlyphAt:effectiveRange:)
. - Get bounding rectangle for
glyphRange
usingNSLayoutManager.boundingRect(forGlyphRange:in:)
- Nudge the bounding rectangle with the source of the text container using
CGRect.offsetBy(dx:dy:)
- Draw your custom underline somewhere between the bottom edge of the offset bounding box and the baseline (as defined
baselineOffset
)
source to share
According to Eiko & Dave's answer, I made an example like this
I tried to end this with a UILabel and not a UITextView but couldn't find a solution after searching from stackoverflow or other websites. So, I used UITextView to do this.
let storage = NSTextStorage()
let layout = UnderlineLayout()
storage.addLayoutManager(layout)
let container = NSTextContainer()
container.widthTracksTextView = true
container.lineFragmentPadding = 0
container.maximumNumberOfLines = 2
container.lineBreakMode = .byTruncatingTail
layout.addTextContainer(container)
let textView = UITextView(frame: CGRect(x: 0, y: 0, width: UIScreen.main.bounds.width-40, height: 50), textContainer: container)
textView.isUserInteractionEnabled = true
textView.isEditable = false
textView.textContainerInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
textView.text = "1sadasdasdasdasdsadasdfdsf"
textView.backgroundColor = UIColor.white
let rg = NSString(string: textView.text!).range(of: textView.text!)
let attributes = [NSAttributedString.Key.underlineStyle.rawValue: 0x11,
NSAttributedString.Key.underlineColor: UIColor.blue.withAlphaComponent(0.2),
NSAttributedString.Key.foregroundColor: UIColor.black, NSAttributedString.Key.font: UIFont.systemFont(ofSize: 17),
NSAttributedString.Key.baselineOffset:10] as! [NSAttributedString.Key : Any]
storage.addAttributes(attributes, range: rg)
view.addSubview(textView)
override LayoutManage method
class UnderlineLayout: NSLayoutManager {
override func drawUnderline(forGlyphRange glyphRange: NSRange, underlineType underlineVal: NSUnderlineStyle, baselineOffset: CGFloat, lineFragmentRect lineRect: CGRect, lineFragmentGlyphRange lineGlyphRange: NSRange, containerOrigin: CGPoint) {
if let container = textContainer(forGlyphAt: glyphRange.location, effectiveRange: nil) {
let boundingRect = self.boundingRect(forGlyphRange: glyphRange, in: container)
let offsetRect = boundingRect.offsetBy(dx: containerOrigin.x, dy: containerOrigin.y)
let left = offsetRect.minX
let bottom = offsetRect.maxY-15
let width = offsetRect.width
let path = UIBezierPath()
path.lineWidth = 3
path.move(to: CGPoint(x: left, y: bottom))
path.addLine(to: CGPoint(x: left + width, y: bottom))
path.stroke()
}
}
In my solution, I have to extend the lineSpacing and also keep the setting underline using the NSAttributedString NSMutableParagraphStyle () property. lineSpacing, but it didn't seem to work, but NSAttributedString.Key.baselineOffset does. hope may
source to share
I know this is a two year old story, but maybe it will help someone facing the same problem as last week. My workaround was to subclass UITextView
, add a subview (container with dashed lines) and use the textViews layoutManager method enumerateLineFragmentsForGlyphRange
to get the number of lines and their frames. Knowing the frame of the lines, I figured y
out where I needed the points. So I created a view (the lineView in which I drew the dots), set clipsToBounds
to YES
, width to width of the text and then added as a subhead in the LineContainer while doing so y
. After that, I set lineViews width
to return valueenumerateLineFragmentsForGlyphRange
... Multiple rows use the same approach: update frames, add new rows, or delete according to what it returns enumerateLineFragmentsForGlyphRange
. This method is called textViewDidChange
after every text update. Here is the code to get an array of strings and their frames
NSLayoutManager *layoutManager = [self layoutManager];
[layoutManager enumerateLineFragmentsForGlyphRange:NSMakeRange(0, [layoutManager numberOfGlyphs])
usingBlock:^(CGRect rect, CGRect usedRect, NSTextContainer *textContainer, NSRange glyphRange, BOOL *stop) {
[linesFramesArray addObject:NSStringFromCGRect(usedRect)];
}
];
source to share