How to get NSAttributedString's underline style attribute to work?
Asked Answered
L

1

11

Using NSAttributedString, I would like to underline a portion of a string. The following code does not work as expected:

let string = NSMutableAttributedString(string: "This is my string.")

string.addAttributes([.underlineStyle : NSUnderlineStyle.single], 
                           range: NSRange(location: 5, length: 2))

One expects "is" to be underlined. Instead, the UITextView layout engine ignores the attribute, and generates a warning in the console. Unhelpfully, the warning makes no mention of underlineStyle or NSUnderlineStyle.

This problem is a common one. There are plenty of examples on StackOverflow that show correct usage. However, while I'm sure there must be some answers that explain the problem, the answers that I've read merely show opaque examples of usage.

This question is posed (and the answer, below, is given) in order to memorialize an explanation of the problem so that future me and others might learn from it.

Luzon answered 5/11, 2018 at 7:16 Comment(1)
Very nice informative question & answer, but it does neglect entirely another issue with the code snippet in question – the hard-coded NSRange. The range of "is" should almost always be calculated. (In this simple example, it obviously doesn't matter, but in most cases.)Pinta
L
26

NSUnderlineStyle Is Not the Proper Type

Precisely, but somewhat unhelpfully, the documentation explains that the underlineStyle attribute key takes an attribute value of an "NSNumber containing an integer."

The value of this attribute is an NSNumber object containing an integer. This value indicates whether the text is underlined and corresponds to one of the constants described in NSUnderlineStyle. The default value for this attribute is styleNone.

Many readers are drawn to NSUnderlineStyle, click on it, and find an enum-like list of styles: single, thick, patternDash, double, etc. That all seems like a good Swifty solution. The reader intuits that this enum must be the way to specify the attribute value, and types the sort of code shown in the question, above.


The rawValue Property of NSUnderlineStyle Provides The Proper Type

NSUnderlineStyle is not an enum. It is a struct that conforms to the OptionSet protocol. The OptionSet protocol is a convenient, Swifty way of setting the bits of an integer, with each bit representing a binary state. The NSUnderlineStyle struct is a wrapper around that integer. Its enum-like styles actually are static vars on the struct, each of which returns an instance of NSUnderlineStyle with the embedded integer, with just one of the bits flipped to correspond to the desired style.

NSAttributedString's dictionary of attributes takes literally anything as a value, so it happily accepts instances of NSUnderlineStyle. But TextKit, the engine that makes UITextView and UILabel work, doesn't know what to do with an NSUnderlineStyle instance. When TextKit tries to render an underlineStyle attribute, it wants an integer so that it can determine the correct style by checking the bits of the integer.

Fortunately, there is an easy way to get at that integer. The rawValue property of NSUnderlineStyle vends it. Thus, a properly working version of our code snippet is as follows:

let string = NSMutableAttributedString(string: "This is my string.")

string.addAttributes([.underlineStyle : NSUnderlineStyle.single.rawValue], 
                           range: NSRange(location: 5, length: 2))

The only difference between this code and the code in the question is the addition of ".rawValue" to "NSUnderlineStyle.single". And, "is" is underlined.

As a side note, the type vended by the rawValue property is an Int, not an NSNumber. The documentation's reference to the attribute value being an NSNumber can be confusing. Under the hood, to interoperate with ObjC, NSAttributedString wraps the Int inside an NSNumber. There is no need for (or benefit to?) explicitly wrapping the Int in NSNumber.


Use the OptionSet union Method to Combine Underline Styles

We can combine multiple underline styles together into a compound style. At the bit-wise level, this is accomplished by taking the logical OR of the integers representing two styles. Being an OptionSet, NSUnderlineStyle provides a Swifty alternative. The union(_:) method on NSUnderlineStyle effectuates the logical OR in a more plain-English fashion. As an example:

NSUnderlineStyle.double.union(.patternDot).rawValue

This code produces an Int with the appropriate bits flipped, and TextKit draws a very pretty dotted double-underline. Of course, not all combinations work. 'NSAttributedString' might accept anything, but common sense and the TextKit engine ultimately will dictate.

Luzon answered 5/11, 2018 at 7:19 Comment(4)
FYI: It's a NSNumber encapsulating an Integer because in Objective-C it's a NSNumber (we can't put primitive like an Int in a NSDictionary), but not in Swift, that's why. And if I remember correctly, you need rawValue in Swift 3 (and less) but not anymore in Swift 4.Palmer
Thanks, Larme. Good info on NSNumber. I'll edit that part to capture the concept.Luzon
@Palmer regarding rawValue, in Swift 4.2, TextKit is giving me an error if I feed it the NSUnderlineStyle object rather than the underlying rawValue. The error is: <NSATSTypesetter: 0x600001a8e640>: Exception -[_SwiftValue _getValue:forType:]: unrecognized selector sent to instance 0x6000021c2940 raised during typesetting layout manager <TextKitExperiments.LayoutManager: 0x6000013bca00>.Luzon
Thank you for the information about the .union method, I had not seen that before with Swift and enum values.Steric

© 2022 - 2024 — McMap. All rights reserved.