Exploring text customisation with NSAttributedString

Uladzislau Volchyk
11 min readNov 4, 2023
Photo by Brooks Leibee on Unsplash

Introduction

In this article, we will learn about NSAttributedString, learn how to apply text attributes to modify its display, and write a small text editor.

Some theory

NSAttributedString

NSAttributedString is a container on top of a string that can contain various text display characteristics: color, font, underlining, etc.

In the basic scenario to create an attributed string one needs to cal an initialiser that accepts a plain string.

NSAttributedString(string: "Hello World!")

A dictionary with attributes can be passed as the second parameter here. In this case, the attributes will be applied to the entire string.

NSAttributedString(
string: "Hello World!",
attributes: [
.foregroundColor: UIColor.blue
]
)

To display an NSAttributesString instance, all we need to do is assign its value to the attributedText property of a UITextView, UITextField or UILabel instance.

NSAttributedString allows specifying attributes only at the initialisation stage. To change attributes in an existing string we should to use NSMutableAttributedString, which is its direct subclass.

addAttribute(_:value:range:) and addAttributes(_:range:) methods are used to add additional attributes. The former allows to specify a value for one attribute, the latter - for several attributes using a dictionary.

let text = NSMutableAttributedString(
string: "Hello World!"
)
text.addAttribute(
.backgroundColor,
value: UIColor.yellow,
range: NSRange(location: 0, length: 5)
)
text.addAttributes(
[.backgroundColor: UIColor.lightGray],
range: NSRange(location: 6, length: 6)
)

range parameter is of type NSRange and specifies the range of values to which the changes should be applied.

One cal also use the setAttributes(_:range:) method to set attributes. Unlike addAttributes(_:range:), this method overwrites existing attributes in the specified range with new ones.

let text = NSMutableAttributedString(
string: "Hello World!",
attributes: [.backgroundColor: UIColor.purple]
)
text.setAttributes(
[.backgroundColor: UIColor.yellow],
range: NSRange(location: 0, length: 5)
)
text.setAttributes(
[.backgroundColor: UIColor.lightGray],
range: NSRange(location: 6, length: 6)
)

Text attributes are defined in the NSAttributedString.Key namespace. The image below shows the full list of available parameters.

Among all the attributes presented, when creating a text editor we will be interested in two: .underlineStyle and .font.

UIFont

.font value is represented by the UIFont type. This type incapsulates different font characteristics: point size, oblique, font family, etc.

To create an instance of UIFont, we can use the initialiser that takes two parameters: font name and point size.

UIFont(name: "Cochin", size: 18.0)

In case the system cannot find the font by the provided name, nil value will be returned.

iOS, macOS, and other systems provide a variety of fonts out of the box. A complete list of supported fonts is available in the official documentation.

To use custom fonts one must add them to the app bundle. See Apple’s guide for more details.

We can also get the system font via the systemFont(ofSize:) method. It returns one of the fonts - San Francisco and New York.

The second UIFont initialiser accepts an instance of UIFontDescriptor instead of the font name. This type describes a set a font attributes (font family, point size, oblique, lettering, etc.) with the possibility of changing it and creating a UIFont object based on it.

Text editor

We will make a small sandbox to apply the previously described theory. It will be a text input field, through which we can change the underline and thickness of the selected text. We will do all this in SwiftUI.

Let’s start by sketching out a vision of what we’d like the text interaction interface to look like.

It has a text canvas and options for customising the text. For the canvas, let’s define the TextCanvas type. The text options will be represented by external mappings. In order to associate options with TextCanvas, let's define the TextContext type: it will contain knowledge about the selected text settings.

Main components

TextEditor

This type will contain properties corresponding to different text characteristics. First, let’s define the isUnderlined and isBold properties. The first indicates whether the text is underlined or not, the second whether the text is bold. By default, we'll assume that both of these properties go with false.

import SwiftUI

final class TextContext: ObservableObject {
@Published var isUnderlined = false
@Published var isBold = false
}

TextCanvas

It is a wrapper over UITextView, which we use to provide access to instances of NSAttributedString and NSMutableAttributedString.

import SwiftUI

struct TextCanvas {
@ObservedObject private var textContext: TextContext
@Binding private var text: NSAttributedString

private let textView: UITextView = {
let view = UITextView()
return view
}()

init(
text: Binding<NSAttributedString>,
textContext: TextContext
) {
_text = text
self.textContext = textContext
}
}

extension TextCanvas: UIViewRepresentable {
func makeUIView(context: Context) -> some UIView {
textView.attributedText = text
return textView
}
func updateUIView(_ uiView: some UIView, context: Context) {}
}

TextCoordinator

To manage changes in UITextView, let's define the TextCoordinator type. It will handle signals from TextContext and apply necessary changes to UITextView.

import SwiftUI

final class TextCoordinator {
private var text: Binding<NSAttributedString>
private let textView: UITextView
private let context: TextContext
init(
text: Binding<NSAttributedString>,
textView: UITextView,
context: TextContext
) {
self.text = text
self.textView = textView
self.context = context
}
}

In TextCanvas let's add a method makeCoordinator, from which we will return just described TextCoordinator.

extension TextCanvas: UIViewRepresentable {

// ...

func makeCoordinator() -> TextCoordinator {
TextCoordinator(text: $text, textView: textView, context: textContext)
}
}

Events handling

Underlining

Let’s define underline event handling in TextCoordinator.

In order to change the parameters of an existing string, we need an instance of NSMutableAttributedString. It can be obtained from UITextView through the textStorage property.

The required parameter has a key .underlineStyle and a value of type NSNumber. We will set the value through the setAttributes method, because we need to overwrite the values of this parameter.

— — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — —

💡 The .underlineStyle attribute has different display styles. For example, you can change the pattern for the underline and make it interrupted. All possible options are described in the NSUnderlineStyle type. For simplicity, we will focus only on the regular underline.

— — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — —

UITextView defines the selectedRange property to get the range selected by the user. We use it as a parameter to set the new attribute.

private extension TextCoordinator {
func updateUnderlined(with value: Bool) {
textView
.textStorage
.setAttributes([
.underlineStyle: NSNumber(value: value)
], range: textView.selectedRange)
}
}

To link TextContext and UITextView to each other, let's create bindings in TextCoordinator:

import Combine
import SwiftUI

final class TextCoordinator {

// ...

private var cancellationBag = Set<AnyCancellable>()

init(
text: Binding<NSAttributedString>,
textView: UITextView,
context: TextContext
) {
// ...

bindContextUpdates()
}
}

private extension TextCoordinator {
func bindContextUpdates() {
context
.$isUnderlined
.sink { [weak self] in self?.updateUnderlined(with: $0) }
.store(in: &cancellationBag)
}
}

Boldface

Lettering, unlike underlining, is a characteristic of the typeface, not the line.

From the NSAttributedString instance, we need to get the font of the selected string. This can be done using the attribute(_:at:effectiveRange:) method.

private extension TextCoordinator {
func updateBold(with value: Bool) {
let font: UIFont = textView.attributedText.attribute(
.font,
at: textView.selectedRange.location,
effectiveRange: nil
) as? UIFont ?? .systemFont(ofSize: 18.0)

// ...

}
}

attribute(_:at:effectiveRange:) raises an exception if the location parameter is outside the attributedText. Therefore, the case when the length of attributedText is zero (the string is empty) must be handled additionally:

private extension TextCoordinator {
func updateBold(with value: Bool) {
let font: UIFont = {
if textView.attributedText.length == .zero {
return .systemFont(ofSize: 18.0)
}
return textView.attributedText.attribute(
.font,
at: textView.selectedRange.location,
effectiveRange: nil
) as? UIFont ?? .systemFont(ofSize: 18.0)
}()

// ...

}
}

Now we can access the UIFontDescriptor of the resulting font and get the symbolicTraits from it.

This field is of type UIFontDescriptor.SymbolicTraits, which defines various stylistic features of the font. Among them is the .traitBold we need. Depending on the value of the passed parameter, we form a new set by adding or removing .traitBold from the original set.

private extension TextCoordinator {
func updateBold(with value: Bool) {

// ...

let originalSymbolicTraits = font.fontDescriptor.symbolicTraits
let newSymbolicTraits = value ? originalSymbolicTraits.union(.traitBold) : originalSymbolicTraits.subtracting(.traitBold)

// ...

}
}

After that, all we need to do is create a new font instance with a customised UIFontDescriptor and set that font to NSMutableAttributedString.

private extension TextCoordinator {
func updateBold(with value: Bool) {

// ...

let newFont: UIFont = UIFont(
descriptor: font.fontDescriptor.withSymbolicTraits(newSymbolicTraits) ?? font.fontDescriptor,
size: font.pointSize
)
textView
.textStorage
.setAttributes([
.font: newFont
], range: textView.selectedRange)
}
}

Also, let’s not forget to add a subscription to changes in the isBold value in the context.

private extension TextCoordinator {
func bindContextUpdates() {
// ...

context
.$isBold
.sink { [weak self] in self?.updateBold(with: $0) }
.store(in: &cancellationBag)
}
}

Test stand

To test the current solution, let’s build a simple test layout on SwiftUI.

import SwiftUI

struct ContentView: View {
@StateObject var textContext = TextContext()
@State private var text = NSAttributedString(
string: "Hello!",
attributes: [
.font: UIFont.systemFont(ofSize: 18.0)
]
)
var body: some View {
VStack {
TextCanvas(
text: $text,
textContext: textContext
)
.frame(height: 400.0)
.clipShape(RoundedRectangle(cornerRadius: 8.0, style: .continuous))
Spacer()
List {
Section("Style") {
HStack {
Button {
textContext.isUnderlined.toggle()
} label: {
Image(systemName: "underline")
.frame(
width: 40.0,
height: 40.0
)
.background(.gray.opacity(0.4))
.cornerRadius(4.0)
}
.buttonStyle(.plain)
.foregroundColor(textContext.isUnderlined ? .blue : .black)
Button {
textContext.isBold.toggle()
} label: {
Image(systemName: "bold")
.frame(
width: 40.0,
height: 40.0
)
.background(.gray.opacity(0.4))
.cornerRadius(4.0)
}
.buttonStyle(.plain)
.foregroundColor(textContext.isBold ? .blue : .black)
}
}
}
.clipShape(RoundedRectangle(cornerRadius: 8.0, style: .continuous))
}
.padding(.horizontal, 16.0)
.background {
Color.gray.opacity(0.3)
.ignoresSafeArea()
}
}
}

Running it, we will notice that the style application mechanism has a few problems:

  • Styles are rewriting each other
  • Values in context may not represent the current state of the text

Refactoring

Styles rewriting

To fix style rewriting, let’s look at the current implementation. The setAttributes(_:range:) method, as mentioned earlier, overwrites the set of styles in the specified range.

private extension TextCoordinator {
func updateUnderlined(with value: Bool) {
textView
.textStorage
.setAttributes([
.underlineStyle: NSNumber(value: value)
], range: textView.selectedRange)
}
}

NSAttributedString describes a method enumerateAttribute(_:in:options:using:), which traverses the values of the selected attribute in the specified range and calls closures with the resulting values. Let's use it and write a method for NSMutableAttributedString that will update the attributes:

extension NSMutableAttributedString {
func updateAttribute(
_ key: NSAttributedString.Key,
with value: Any,
in range: NSRange
) {
guard length > .zero, range.location >= .zero else { return }

beginEditing()
enumerateAttribute(key, in: range) { _, range, _ in
removeAttribute(key, range: range)
addAttribute(key, value: value, range: range)
}
endEditing()
}
}

In the callback, we remove the previous value of the selected attribute via removeAttribute(:range:) **and set a new one via addAttribute(:value:range:). In this way we affect the value of only one attribute within the selected range. The calls beginEditing and endEditing allow us to optimise the process of making changes.

Let’s apply the newly created method:

private extension TextCoordinator {
func updateUnderlined(with value: Bool) {
textView.textStorage
.updateAttribute(
.underlineStyle,
with: NSNumber(value: value),
in: textView.selectedRange
)
}

func updateBold(with value: Bool) {

// ...

let newFont: UIFont = UIFont(
descriptor: font.fontDescriptor.withSymbolicTraits(newSymbolicTraits) ?? font.fontDescriptor,
size: font.pointSize
)
textView.textStorage
.updateAttribute(
.font,
with: newFont,
in: textView.selectedRange
)
}
}

Now the specified styles can be applied simultaneously.

Context synchronisation

The second task is to synchronise context and styles in the selected range. The way we will do it is simple: when changing the range of selected values, we will check the presence of attributes we are interested in and update the values in the context based on this.

Let’s start by getting the attributes. We already did it earlier in the updateBold(with:) method, using attribute(_:at:effectiveRange:).

private extension TextCoordinator {
func updateBold(with value: Bool) {
let font: UIFont = {
if textView.attributedText.length == .zero {
return .systemFont(ofSize: 18.0)
}
return textView.attributedText.attribute(
.font,
at: textView.selectedRange.location,
effectiveRange: nil
) as? UIFont ?? .systemFont(ofSize: 18.0)
}()

// ...

}
}

For ease of further use, let’s describe the extension for NSAttributedString:

extension NSAttributedString {
func safeRange(for range: NSRange) -> NSRange {
NSRange(
location: max(.zero, min(length - 1, range.location)),
length: min(range.length, max(.zero, length - range.location))
)
}

func textAttributes(in range: NSRange) -> [Key: Any] {
if length == .zero { return [:] }
let range = safeRange(for: range)
return attributes(at: range.location, effectiveRange: nil)
}
}

safeRange(for:), as the name implies, returns a safe-to-use range of values. It checks that location and length are within the current string. This avoids the error described earlier, which can be caused by calling attribute(_:at:effectiveRange:).

textAttributes(in:), using a safe range get, returns the attributes of a string as a dictionary.

Now we can update the updateBold(with:) method and use functions just described to retrieve the font:

Now we can update the updateBold(with:) method and use the functions just described to get the font:

private extension TextCoordinator {
func updateBold(with value: Bool) {
let font: UIFont = textView.attributedText.textAttributes(in: textView.selectedRange)[.font] as? UIFont ?? .systemFont(ofSize: 18.0)

// ...

}
}

Let’s describe the synchronisation method. At the beginning we get a user-selected range of values and attributes from this range.

private extension TextCoordinator {
func updateContextFromTextView() {
let range = textView.selectedRange
let attributes = textView.attributedText.textAttributes(in: range)

// ...

}
}

Since we know that underline is a characteristic of a string, we get its current value through the .underlineStyle key. A value of 1 corresponds to the state when the text is underlined. We set this value to the context property isUnderline.

private extension TextCoordinator {
func updateContextFromTextView() {

// ...

let isUnderlined = attributes[.underlineStyle] as? Int == 1
context.isUnderlined = isUnderlined

// ...

}
}

Boldface is a font characteristic, so first we need to get the font value via the .font key. Next, we access the font fontDescriptor of the font and check for .traitBold in the list of font characteristics. We also assign the resulting value to the context.

private extension TextCoordinator {
func updateContextFromTextView() {

// ...

guard let font = attributes[.font] as? UIFont else { return }
let isBold = font.fontDescriptor.symbolicTraits.contains(.traitBold)
context.isBold = isBold
}
}

To track changes to the selected range in the UITextView, let's make TextCoordinator its delegate:

final class TextCoordinator: NSObject {
private var text: Binding<NSAttributedString>
private let textView: UITextView
private let context: TextContext

private var cancellationBag = Set<AnyCancellable>()

init(
text: Binding<NSAttributedString>,
textView: UITextView,
context: TextContext
) {
self.text = text
self.textView = textView
self.context = context
super.init()
textView.delegate = self
bindContextUpdates()
}
}

The methods we are interested in are textViewDidChange(:) and textViewDidChangeSelection(:). Let's override them by calling the updateContextFromTextView() method described earlier.

extension TextCoordinator: UITextViewDelegate {
func textViewDidChangeSelection(_ textView: UITextView) {
updateContextFromTextView()
}

func textViewDidChange(_ textView: UITextView) {
updateContextFromTextView()
}
}

Done, now the context values are synchronised with the user selected text.

Conclusion

We have looked at the possibilities of interacting with NSAttributedString and learned how to interact with text attributes to change its display. The functionality of the written text editor can be extended by adding interaction with other attributes (foreground/background color, style combinations, oblique, etc.) and saving or clearing attributes for writing text in typingAttributes.

--

--