Attributed Strings

I had to take a string like “Name (whatever)” and display it in a label. The Name part should be bold, and anything in parentheses should use the regular font.

Pretty simple, I just put the string in a NSAttributedString, find the opening parenthesis and add a bold font attribute to everything up to it. If I find no parenthesis I just bold the whole string:

let attributed = NSMutableAttributedString(string: string)
let boldRange = NSRange(string.startIndex ..< (string.range(of: " (")?.lowerBound ?? string.endIndex), in: string)
attributed.addAttributes([.font: UIFont.systemFont(ofSize: 12, weight: .bold)], range: boldRange)Code language: Swift (swift)

But where should I put this code? The part that specifies the font and other presentation specific things should belong to the view layer. Especially since it has to import UIKit to get UIFont. But parsing the string should go in the model layer. In other views I might want to display that string in a different way, so it makes sense to parse it once.

In this case I could split the string and store both parts separately. Another option is to store the range of the name in addition to the whole string. Both approaches are not very scalable. Once I have multiple ranges to highlight this gets too complicated. What I need is a data structure that can assign values to ranges in a string. There are tons of options how to implement this. But luckily I don’t have to since Foundation provides such a thing: NSAttributedString.

First I define a key for a custom attribute and a type for that attribute:

extension NSAttributedString.Key {
    static let semantic = NSAttributedString.Key(rawValue: "de.5sw.semantic")
}

enum Semantic: Hashable {
    case name
}Code language: Swift (swift)

Then I parse the string and add the custom semantic attribute:

let attributed = NSMutableAttributedString(string: string)
let nameRange = NSRange(string.startIndex ..< (string.range(of: " (")?.lowerBound ?? string.endIndex), in: string)
attributed.addAttributes([.semantic: Semantic.name], range: nameRange)Code language: Swift (swift)

The code is basically the same as before, but instead of adding the font attribute I add a custom attribute that specifies the meaning of this part of text.

In the view layer I then enumerate all the ranges with the semantic attribute and add the expected formatting:

let displayString = NSMutableAttributedString(attributedString: model.parsedString)
displayString.beginEditing()
displayString.enumerateAttribute(.semantic, in: NSRange(location: 0, length: displayString.length), options: .longestEffectiveRangeNotRequired) { value, range, _ in
    if let semantic = value as? Semantic, semantic == Semantic.name {
        displayString.setAttributes([.font: UIFont.systemFont(ofSize: 12, weight: .bold)], range: range)
    } else {
        displayString.removeAttribute(.semantic, range: range)
    }
}
displayString.endEditing()Code language: Swift (swift)

I like this code, now I have a clear separation between the meaning of my data and the way it should be presented.

And since I don’t want to repeat this everywhere I need to style an attributed string I encapsulated this in a handy little extension that uses a dictionary to map my custom attribute to actual styles:

extension NSAttributedString {
    convenience init<T>(attributedString: NSAttributedString, key: NSAttributedString.Key, attributes: [T: [NSAttributedString.Key: Any]], default: [NSAttributedString.Key: Any]? = nil) {

        let copy = NSMutableAttributedString(attributedString: attributedString)
        copy.beginEditing()
        copy.enumerateAttribute(key, in: NSRange(location: 0, length: copy.length), options: .longestEffectiveRangeNotRequired) { attribute, range, _ in
            if let value = attribute as? T, let newAttributes = attributes[value] ?? `default` {
                copy.setAttributes(newAttributes, range: range)
            } else {
                copy.removeAttribute(key, range: range)
            }
        }
        copy.endEditing()

        self.init(attributedString: copy)
    }
}Code language: Swift (swift)