Text customisation with NSAttributedString
November 4, 2023
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.
A dictionary with attributes can be passed as the second parameter here. In this case, the attributes will be applied to the entire string.
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.
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.
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: NSAttributedString.Key.underlineStyle
and NSAttributedString.Key.font
.
UIFont
NSAttributedString.Key.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.
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
TextContext
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
.
TextCanvas
It is a wrapper over UITextView
, which we use to provide access to instances of NSAttributedString
and NSMutableAttributedString
.
To manage changes in UITextView
, let's define the TextCoordinator
type. It will handle signals from TextContext
and apply necessary changes to UITextView
.
In TextCanvas
let's add a method makeCoordinator
, from which we will return just described TextCoordinator
.
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 theNSUnderlineStyle
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.
To link TextContext
and UITextView
to each other, let's create bindings in TextCoordinator
:
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.
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:
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.
After that, all we need to do is create a new font instance with a customised UIFontDescriptor
and set that font to NSMutableAttributedString
.
Also, let's not forget to add a subscription to changes in the isBold
value in the context.
Test stand
To test the current solution, let's build a simple test layout on SwiftUI.
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.
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:
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:
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:)
.
For ease of further use, let's describe the extension for NSAttributedString
:
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:
Let's describe the synchronisation method. At the beginning we get a user-selected range of values and attributes from this 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
.
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.
To track changes to the selected range in the UITextView
, let's make TextCoordinator
its delegate:
The methods we are interested in are textViewDidChange(:)
and textViewDidChangeSelection(:)
. Let's override them by calling the updateContextFromTextView()
method described earlier.
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
.