NSAttributedString is used for displaying rich text on Apple platforms. There are many attributes available for styling the text. Let’s see how to add new attribute and how to draw it in UITextView.
Drawing custom attribute
Defining a new attribute is just a matter of extending NSAttributedString.Key and defining a new static variable. Difficult part is actually drawing the custom attribute. Apple’s documentation says that best way for it is to subclass NSLayoutManager and overriding drawGlyphs(forGlyphRange:at:). This is what we are doing here as well. Whenever glyphs are drawn, we check if the glyph range contains the custom attribute, if it does, then we get the rects for glyphs containing the custom attribute. When we know rects, we can draw the custom styling. In this case, we’ll mimic tokens in text and therefore go for rectangle with rounded corners. Should be noted that enumerating custom attribute, longestEffectiveRangeNotRequired should be specified. Otherwise, if we have two different tokens next to each-other, then this attribute is considered as one.
UITextView has by default text container inset set. For being able to draw the custom styling exactly behind the text, this offset must be taken account. Easiest is to propagate insets to layout manager what we are doing here.
extension NSAttributedString.Key {
static let token = NSAttributedString.Key("Token")
}
final class TokenLayoutManager: NSLayoutManager {
var textContainerOriginOffset: CGSize = .zero
override func drawGlyphs(forGlyphRange glyphsToShow: NSRange, at origin: CGPoint) {
let characterRange = self.characterRange(forGlyphRange: glyphsToShow, actualGlyphRange: nil)
textStorage?.enumerateAttribute(.token, in: characterRange, options: .longestEffectiveRangeNotRequired, using: { (value, subrange, _) in
guard let token = value as? String, !token.isEmpty else { return }
let tokenGlypeRange = glyphRange(forCharacterRange: subrange, actualCharacterRange: nil)
drawToken(forGlyphRange: tokenGlypeRange)
})
super.drawGlyphs(forGlyphRange: glyphsToShow, at: origin)
}
private func drawToken(forGlyphRange tokenGlypeRange: NSRange) {
guard let textContainer = textContainer(forGlyphAt: tokenGlypeRange.location, effectiveRange: nil) else { return }
let withinRange = NSRange(location: NSNotFound, length: 0)
enumerateEnclosingRects(forGlyphRange: tokenGlypeRange, withinSelectedGlyphRange: withinRange, in: textContainer) { (rect, _) in
let tokenRect = rect.offsetBy(dx: self.textContainerOriginOffset.width, dy: self.textContainerOriginOffset.height)
UIColor(hue: 175.0/360.0, saturation: 0.24, brightness: 0.88, alpha: 1).setFill()
UIBezierPath(roundedRect: tokenRect, cornerRadius: 4).fill()
}
}
}
Using custom layout manager in UITextView requires a little bit of setup what we can put into UITextView’s subclass.
final class TokenTextView: UITextView {
init(frame: CGRect) {
let layoutManager = TokenLayoutManager()
let textStorage = NSTextStorage()
textStorage.addLayoutManager(layoutManager)
let textContainer = NSTextContainer()
textContainer.heightTracksTextView = true
textContainer.widthTracksTextView = true
layoutManager.addTextContainer(textContainer)
super.init(frame: frame, textContainer: textContainer)
updateLayoutManager()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override var textContainerInset: UIEdgeInsets {
didSet {
updateLayoutManager()
}
}
private func updateLayoutManager() {
guard let layoutManager = layoutManager as? TokenLayoutManager else { return }
layoutManager.textContainerOriginOffset = CGSize(width: textContainerInset.left, height: textContainerInset.top)
layoutManager.invalidateDisplay(forCharacterRange: NSRange(location: 0, length: attributedText.length))
}
}
let textView = TokenTextView(frame: .zero)
textView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(textView)
NSLayoutConstraint.activate([
textView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 16),
textView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -16),
textView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16),
textView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -16)
])
let string = "The quick brown fox jumps over the lazy dog"
let attributedString = NSMutableAttributedString(string: string)
let value = "value"
attributedString.addAttribute(.token, value: value, range: NSRange(location: 4, length: 5))
textView.attributedText = attributedString
Summary
We defined a new attribute by extending NSAttributedString.Key. Then we created a new NSLayoutManager subclass and added custom styling to the attribute. Lastly, we configured the UITextView to use custom layout manager and set text with custom attribute to it.
If this was helpful, please let me know on Mastodon@toomasvahter or Twitter @toomasvahter. Feel free to subscribe to RSS feed. Thank you for reading.
Example project
NSAttributedStringCustomAttribute Xcode 11.2, Swift 5.1

4 replies on “Adding custom attribute to NSAttributedString on iOS”
[…] and so on. I finally managed to get noticed by iOS Dev Weekly and the issue 430 contained my post Adding custom attribute to NSAttributedString on iOS. Really happy about it!All in all, I am very happy to see how my blog grew in 2019. Total view […]
LikeLike
[…] Adding custom attribute to NSAttributedString on iOS (November 10, 2019) […]
LikeLike
[…] With this set we can create attributed strings with this attribute either using markdown syntax or adding the attribute manually. Markdown syntax for custom attributes uses caret followed with square brackets and with the content in brackets after that. We also need to make sure to pass custom attribute scope into the AttributedString initializer as well. One thing what I have not figured out is how to create a completely custom appearance for custom attributes in SwiftUI views, like we can do in UIKit views. […]
LikeLike
[…] Adding custom attribute to NSAttributedString on iOS (November 10, 2019) […]
LikeLike