At WWDC 2021, Apple introduced a long-awaited feature for developers - AttributedString, which means Swift developers no longer need to use Objective-C-based NSAttributedString to create styled text. This article will provide a comprehensive introduction to AttributedString and demonstrate how to create custom attributes.
First Impression
AttributedString is a string with attributes for a single character or character range. Attributes provide features such as visual styles for display, accessibility guidance, and hyperlink data for linking between data sources.
The following code will generate an attributed string that contains bold and hyperlinked text.
var attributedString = AttributedString("Please visit Zhouzi's blog")
let zhouzi = attributedString.range(of: "Zhouzi")! // Get the range of "Zhouzi"
attributedString[zhouzi].inlinePresentationIntent = .stronglyEmphasized // Set the attribute - bold
let blog = attributedString.range(of: "blog")!
attributedString[blog].link = URL(string: "<https://fatbobman.com>")! // Set the attribute - hyperlink
Before WWDC 2021, SwiftUI did not provide support for attributed strings. If we wanted to display text with rich styles, we would usually use one of the following three methods:
- Wrap UIKit or AppKit controls into SwiftUI controls and display NSAttributedString in them
- Convert NSAttributedString into corresponding SwiftUI layout code through code
- Display using native SwiftUI control combinations
With the changes in SwiftUI versions, there are constantly increasing means available (without using NSAttributedString):
SwiftUI 1.0
@ViewBuilder
var helloView:some View{
HStack(alignment:.lastTextBaseline, spacing:0){
Text("Hello").font(.title).foregroundColor(.red)
Text(" world").font(.callout).foregroundColor(.cyan)
}
}
SwiftUI 2.0
SwiftUI 2.0 enhanced Text’s functionality, allowing us to merge different Texts to display them together using +
.
var helloText:Text {
Text("Hello").font(.title).foregroundColor(.red) + Text(" world").font(.callout).foregroundColor(.cyan)
}
SwiftUI 3.0
In addition to the above methods, Text now has native support for AttributedString.
var helloAttributedString:AttributedString {
var hello = AttributedString("Hello")
hello.font = .title.bold()
hello.foregroundColor = .red
var world = AttributedString(" world")
world.font = .callout
world.foregroundColor = .cyan
return hello + world
}
Text(helloAttributedString)
Simply looking at the above examples, you may not see the advantages of AttributedString. I believe that with continued reading of this article, you will find that AttributedString can achieve many functions and effects that were previously impossible.
AttributedString vs NSAttributedString
AttributedString can basically be seen as the Swift implementation of NSAttributedString, and there is not much difference in functionality and internal logic between the two. However, due to differences in formation time, core code language, etc., there are still many differences between them. This section will compare them from multiple aspects.
Type
AttributedString is a value type, which is also the biggest difference between it and NSAttributedString (reference type) constructed by Objective-C. This means that it can be passed, copied, and changed like other values through Swift’s value semantics.
NSAttributedString requires different definitions for mutable or immutable
let hello = NSMutableAttributedString("hello")
let world = NSAttributedString(" world")
hello.append(world)
AttributedString
var hello = AttributedString("hello")
let world = AttributedString(" world")
hello.append(world)
Safety
In AttributedString, it is necessary to use Swift’s dot or key syntax to access attributes by name, which not only ensures type safety, but also has the advantage of compile-time checking.
AttributedString rarely uses the property access method of NSAttributedString to greatly reduce the chance of errors:
// Possible type mismatch
let attributes: [NSAttributedString.Key: Any] = [
.font: UIFont.systemFont(ofSize: 72),
.foregroundColor: UIColor.white,
]
Localization Support
AttributedString provides native support for localized strings and can add specific properties to them.
var localizableString = AttributedString(localized: "Hello \(Date.now,format: .dateTime) world", locale: Locale(identifier: "zh-cn"), option: .applyReplacementIndexAttribute)
Formatter Support
The new Formatter API introduced in WWDC 2021 fully supports formatting output for AttributedString types. We can easily achieve tasks that were previously impossible.
var dateString: AttributedString {
var attributedString = Date.now.formatted(.dateTime
.hour()
.minute()
.weekday()
.attributed
)
let weekContainer = AttributeContainer()
.dateField(.weekday)
let colorContainer = AttributeContainer()
.foregroundColor(.red)
attributedString.replaceAttributes(weekContainer, with: colorContainer)
return attributedString
}
Text(dateString)
For more examples of the new Formatter API working with AttributedString, please refer to WWDC 2021’s “New and Old Comparison and Customization of the New Formatter API.”
SwiftUI Integration
The Text component in SwiftUI natively supports AttributedString, which improves a long-standing pain point for SwiftUI (although TextField and TextEdit still do not support it).
AttributedString provides available properties for three frameworks: SwiftUI, UIKit, and AppKit. UIKit or AppKit controls can also render AttributedString (after conversion).
Supported File Formats
Currently, AttributedString only has the ability to parse Markdown-format text. There is still a big gap compared to NSAttributedString’s support for Markdown, RTF, DOC, and HTML.
Conversion
Apple provides the ability to convert between AttributedString and NSAttributedString.
// AttributedString -> NSAttributedString
let nsString = NSMutableAttributedString("hello")
var attributedString = AttributedString(nsString)
// NSAttribuedString -> AttributedString
var attString = AttributedString("hello")
attString.uiKit.foregroundColor = .red
let nsString1 = NSAttributedString(attString)
Developers can take full advantage of the strengths of both to develop, for example:
- Parse HTML with NSAttributedString, then convert it to AttributedString
- Create a type-safe string with AttributedString and convert it to NSAttributedString for display
Basics
In this section, we will introduce some important concepts in AttributedString and demonstrate more usage through code snippets.
AttributedStringKey
AttributedStringKey defines the attribute name and type in AttributedString. Using dot syntax or KeyPath, we can access them in a type-safe manner.
var string = AttributedString("hello world")
// Using dot syntax
string.font = .callout
let font = string.font
// Using KeyPath
let font = string[keyPath:\.font]
In addition to using a large number of pre-defined system attributes, we can also create our own attributes, for example:
enum OutlineColorAttribute : AttributedStringKey {
typealias Value = Color // Attribute type
static let name = "OutlineColor" // Attribute name
}
string.outlineColor = .blue
We can access the properties of AttributedString, AttributedSubString, AttributeContainer, and AttributedString.Runs.Run using dot syntax or KeyPath. Refer to other code snippets in this article for more usage.
AttributeContainer
AttributeContainer is a container for attributes. By configuring the container, we can set, replace, and merge a large number of attributes for a string (or fragment) at once.
Setting Attributes
var attributedString = AttributedString("Swift")
string.foregroundColor = .red
var container = AttributeContainer()
container.inlinePresentationIntent = .strikethrough
container.font = .caption
container.backgroundColor = .pink
container.foregroundColor = .green //Will override the original red
attributedString.setAttributes(container) // attributedString now has four attribute contents
Replacing Attributes
var container = AttributeContainer()
container.inlinePresentationIntent = .strikethrough
container.font = .caption
container.backgroundColor = .pink
container.foregroundColor = .green
attributedString.setAttributes(container)
// At this point, attributedString has four attribute contents: font, backgroundColor, foregroundColor, inlinePresentationIntent
// Attributes to be replaced
var container1 = AttributeContainer()
container1.foregroundColor = .green
container1.font = .caption
// Attributes to replace with
var container2 = AttributeContainer()
container2.link = URL(string: "<https://www.swift.org>")
// All property key-value pairs in container1 that match will be replaced, for example, if container1's foregroundColor is .red, it will not be replaced
attributedString.replaceAttributes(container1, with: container2)
// After replacement, attributedString has three attribute contents: backgroundColor, inlinePresentationIntent, link
Merging Attributes
var container = AttributeContainer()
container.inlinePresentationIntent = .strikethrough
container.font = .caption
container.backgroundColor = .pink
container.foregroundColor = .green
attributedString.setAttributes(container)
// At this point, attributedString has four attribute contents: font, backgroundColor, foregroundColor, inlinePresentationIntent
var container2 = AttributeContainer()
container2.foregroundColor = .red
container2.link = URL(string: "www.swift.org")
attributedString.mergeAttributes(container2,mergePolicy: .keepNew)
// After merging, attributedString has five attribute contents: font, backgroundColor, foregroundColor, inlinePresentationIntent, and link
// foreground is .red
// When attributes conflict, use mergePolicy to choose the merge strategy: .keepNew (default) or .keepCurrent
AttributeScope
AttributeScope is a collection of attributes defined by the system framework, which defines a set of attributes suitable for a specific domain. This makes it easier to manage and also solves the problem of inconsistent attribute type for the same attribute name across different frameworks.
Currently, AttributedString provides 5 preset Scopes, which are:
-
foundation
Contains properties related to Formatter, Markdown, URL, and language transformation.
-
swiftUI
The properties that can be rendered under SwiftUI, such as foregroundColor, backgroundColor, font, etc. The currently supported properties are significantly less than uiKit and appKit. It is estimated that other unsupported properties will gradually be added in the future as SwiftUI provides more display support.
-
uiKit
The properties that can be rendered under UIKit.
-
appKit
The properties that can be rendered under AppKit.
-
accessibility
Properties suitable for accessibility, used to improve the usability of guided access.
There are many properties with the same name in the swiftUI, uiKit, and appKit scopes (such as foregroundColor). When accessing them, the following points should be noted:
- When Xcode cannot infer which AttributeScope to apply to a property, explicitly indicate the corresponding AttributeScope.
uiKitString.uiKit.foregroundColor = .red //UIColor
appKitString.appKit.backgroundColor = .yellow //NSColor
- The same-named properties of the three frameworks cannot be converted to each other. If you want the string to support multi-framework display (code reuse), assign the same-named properties of different scopes separately.
attributedString.swiftUI.foregroundColor = .red
attributedString.uiKit.foregroundColor = .red
attributedString.appKit.foregroundColor = .red
// To convert it to NSAttributedString, you can convert only the specified Scope properties
let nsString = try! NSAttributedString(attributedString, including: \.uiKit)
- In order to improve compatibility, some properties with the same function can be set in foundation.
attributedString.inlinePresentationIntent = .stronglyEmphasized // equivalent to bold
- When defining swiftUI, uiKit, and appKit three Scopes, they have already included foundation and accessibility respectively. Therefore, even if only a single framework is specified during conversion, the properties of foundation and accessibility can also be converted normally. It is best to follow this principle when customizing Scopes.
let nsString = try! NSAttributedString(attributedString, including: \.appKit)
// The properties belonging to foundation and accessibility in attributedString will also be converted together.
Views
In the attributed string, attributes and text can be accessed independently. AttributedString provides three views to allow developers to access the content they need from another dimension.
Character and UnicodeScalar views
These two views provide functionality similar to the string property of NSAttributedString, allowing developers to manipulate data in the dimension of plain text. The only difference between the two views is the type. In simple terms, you can think of CharacterView as a collection of characters, and UnicodeScalarView as a collection of Unicode scalars.
String length
var attributedString = AttributedString("Swift")
attributedString.characters.count // 5
Length 2
let attributedString = AttributedString("hello 👩🏽🦳")
attributedString.characters.count // 7
attributedString.unicodeScalars.count // 10
Convert to string
String(attributedString.characters) // "Swift"
Replace String
var attributedString = AttributedString("hello world")
let range = attributedString.range(of: "hello")!
attributedString.characters.replaceSubrange(range, with: "good")
// good world, the replaced "good" still retains all the attributes of the original "hello" position
Runs View
The attribute view of the AttributedString. Each Run corresponds to a string segment with identical attributes. Use the for-in syntax to iterate over the runs property of the AttributedString.
Only One Run
All character attributes in the entire attributed string are consistent.
let attributedString = AttribuedString("Core Data")
print(attributedString)
// Core Data {}
print(attributedString.runs.count) // 1
Two Runs
Attribute string coreData
has two Runs because the attributes of the two fragments, Core
and Data
, are not the same.
var coreData = AttributedString("Core")
coreData.font = .title
coreData.foregroundColor = .green
coreData.append(AttributedString(" Data"))
for run in coreData.runs { //runs.count = 2
print(run)
}
// Core {
// SwiftUI.Font = Font(provider: SwiftUI.(unknown context at $7fff5cd3a0a0).FontBox<SwiftUI.Font.(unknown context at $7fff5cd66db0).TextStyleProvider>)
// SwiftUI.ForegroundColor = green
// }
// Data {}
Multiple Runs
var multiRunString = AttributedString("The attributed runs of the attributed string, as a view into the underlying string.")
while let range = multiRunString.range(of: "attributed") {
multiRunString.characters.replaceSubrange(range, with: "attributed".uppercased())
multiRunString[range].inlinePresentationIntent = .stronglyEmphasized
}
var n = 0
for run in multiRunString.runs {
n += 1
}
// n = 5
Final output: The ATTRIBUTED runs of the ATTRIBUTED string, as a view into the underlying string.
Using Range of Run to Set Attributes
// Continue to use the multiRunString from previous example
// Set all non-strongly emphasized characters to yellow color
for run in multiRunString.runs {
guard run.inlinePresentationIntent != .stronglyEmphasized else {continue}
multiRunString[run.range].foregroundColor = .yellow
}
Getting Specific Attributes through Runs
// Change the text that is yellow and strongly emphasized to red color
for (color,intent,range) in multiRunString.runs[\.foregroundColor,\.inlinePresentationIntent] {
if color == .yellow && intent == .stronglyEmphasized {
multiRunString[range].foregroundColor = .red
}
}
Collecting All Used Attributes through Run’s Attributes
var totalKeysContainer = AttributeContainer()
for run in multiRunString.runs{
let container = run.attributes
totalKeysContainer.merge(container)
}
Using the Runs view makes it easy to get the necessary information from many attributes.
Achieve similar effects without using the Runs view
multiRunString.transformingAttributes(\.foregroundColor,\.font){ color,font in
if color.value == .yellow && font.value == .title {
multiRunString[color.range].backgroundColor = .green
}
}
Although the Runs view is not directly called, the timing of the call of the transformingAttributes closure is consistent with that of Runs. transformingAttributes supports up to 5 properties.
Range
In the code before this article, Range has been used multiple times to access or modify the attributes of the attributed string content.
There are two ways to modify the attributes of local content in an attributed string:
- Through Range
- Through AttributedContainer
Get Range by keyword
// Search backward from the end of the attributed string and return the first range that satisfies the keyword (case insensitive)
if let range = multiRunString.range(of: "Attributed", options: [.backwards, .caseInsensitive]) {
multiRunString[range].link = URL(string: "<https://www.apple.com>")
}
Get Range through Runs or transformingAttributes
Runs or transformingAttributes has been used repeatedly in the previous examples.
Get Range through this article view
if let lowBound = multiRunString.characters.firstIndex(of: "r"),
let upperBound = multiRunString.characters.firstIndex(of: ","),
lowBound < upperBound
{
multiRunString[lowBound...upperBound].foregroundColor = .brown
}
Localization
Create localized attributed strings
// Localizable Chinese
"hello" = "你好";
// Localizable English
"hello" = "hello";
let attributedString = AttributedString(localized: "hello")
In English and Chinese environments, they will be displayed as hello
and 你好
respectively.
At present, localized AttributedString can only be displayed in the language currently set in the system and cannot be specified as a specific language.
var hello = AttributedString(localized: "hello")
if let range = hello.range(of: "h") {
hello[range].foregroundColor = .red
}
The text content of the localized string will change with the system language. The above code will not be able to obtain the range in a Chinese environment. Adjustments need to be made for different languages.
replacementIndex
You can set an index for the interpolation content of a localized string (via applyReplacementIndexAttribute
) to facilitate searching in localized content.
// Localizable Chinese
"world %@ %@" = "%@ 世界 %@";
// Localizable English
"world %@ %@" = "world %@ %@";
var world = AttributedString(localized: "world \("👍") \("🥩")",options: .applyReplacementIndexAttribute) // When creating an attributed string, the index will be set in the order of interpolation, 👍 index == 1 🥩 index == 2
for (index,range) in world.runs[\.replacementIndex] {
switch index {
case 1:
world[range].baselineOffset = 20
world[range].font = .title
case 2:
world[range].backgroundColor = .blue
default:
world[range].inlinePresentationIntent = .strikethrough
}
}
In Chinese and English environments, respectively:
Using locale to set Formatter in string interpolation
AttributedString(localized: "\(Date.now, format: Date.FormatStyle(date: .long))", locale: Locale(identifier: "zh-cn"))
// Will display "2021年10月7日" even in an English environment
Generating attributed strings with Formatter
var dateString = Date.now.formatted(.dateTime.year().month().day().attributed)
dateString.transformingAttributes(\.dateField) { dateField in
switch dateField.value {
case .month:
dateString[dateField.range].foregroundColor = .red
case .day:
dateString[dateField.range].foregroundColor = .green
case .year:
dateString[dateField.range].foregroundColor = .blue
default:
break
}
}
Markdown Symbols
Starting from SwiftUI 3.0, Text has provided support for some Markdown tags. Similar functionality is also available in localized attributed strings, which will set corresponding attributes in the string, providing greater flexibility.
var markdownString = AttributedString(localized: "**Hello** ~world~ _!_")
for (inlineIntent,range) in markdownString.runs[\.inlinePresentationIntent] {
guard let inlineIntent = inlineIntent else {continue}
switch inlineIntent{
case .stronglyEmphasized:
markdownString[range].foregroundColor = .red
case .emphasized:
markdownString[range].foregroundColor = .green
case .strikethrough:
markdownString[range].foregroundColor = .blue
default:
break
}
}
Markdown Parsing
AttributedString not only supports partial Markdown tags in localized strings, but also provides a complete Markdown parser.
It supports parsing Markdown text content from String, Data, or URL.
For example:
let mdString = try! AttributedString(markdown: "# Title\n**hello**\n")
print(mdString)
// Parsing results
Title {
NSPresentationIntent = [header 1 (id 1)]
}
hello {
NSInlinePresentationIntent = NSInlinePresentationIntent(rawValue: 2)
NSPresentationIntent = [paragraph (id 2)]
}
After parsing, the text style and tags will be set in inlinePresentationIntent
and presentationIntent
.
-
inlinePresentationIntent
Character properties: such as bold, italic, code, quote, etc.
-
presentationIntent
Paragraph attributes: such as paragraph, table, list, etc. In a Run, presentationIntent may have multiple contents, which can be obtained using component.
README.md
# Hello
## Header2
hello **world**
* first
* second
> test `print("hello world")`
| row1 | row2 |
| ---- | ---- |
| 34 | 135 |
[新Formatter介绍](/posts/newFormatter/)
Code analysis:
let url = Bundle.main.url(forResource: "README", withExtension: "md")!
var markdownString = try! AttributedString(contentsOf: url,baseURL: URL(string: "<https://fatbobman.com>"))
Result after analysis (excerpt):
Hello {
NSPresentationIntent = [header 1 (id 1)]
}
Header2 {
NSPresentationIntent = [header 2 (id 2)]
}
first {
NSPresentationIntent = [paragraph (id 6), listItem 1 (id 5), unorderedList (id 4)]
}
test {
NSPresentationIntent = [paragraph (id 10), blockQuote (id 9)]
}
print("hello world") {
NSPresentationIntent = [paragraph (id 10), blockQuote (id 9)]
NSInlinePresentationIntent = NSInlinePresentationIntent(rawValue: 4)
}
row1 {
NSPresentationIntent = [tableCell 0 (id 13), tableHeaderRow (id 12), table [Foundation.PresentationIntent.TableColumn(alignment: Foundation.PresentationIntent.TableColumn.Alignment.left), Foundation.PresentationIntent.TableColumn(alignment: Foundation.PresentationIntent.TableColumn.Alignment.left)] (id 11)]
}
row2 {
NSPresentationIntent = [tableCell 1 (id 14), tableHeaderRow (id 12), table [Foundation.PresentationIntent.TableColumn(alignment: Foundation.PresentationIntent.TableColumn.Alignment.left), Foundation.PresentationIntent.TableColumn(alignment: Foundation.PresentationIntent.TableColumn.Alignment.left)] (id 11)]
}
新Formatter介绍 {
NSPresentationIntent = [paragraph (id 18)]
NSLink = /posts/newFormatter/ -- https://fatbobman.com
}
The parsed content includes paragraph properties, header numbers, table column and row numbers, alignment, and so on. Other information such as indentation and numbering can be handled by enumerating associated values in the code.
The approximate code is as follows:
for run in markdownString.runs {
if let inlinePresentationIntent = run.inlinePresentationIntent {
switch inlinePresentationIntent {
case .strikethrough:
print("strikethrough")
case .stronglyEmphasized:
print("bold")
default:
break
}
}
if let presentationIntent = run.presentationIntent {
for component in presentationIntent.components {
switch component.kind{
case .codeBlock(let languageHint):
print(languageHint)
case .header(let level):
print(level)
case .paragraph:
let paragraphID = component.identity
default:
break
}
}
}
}
SwiftUI does not support rendering of attached information in presentationIntent. If you want to achieve the desired display effect, please write your own code for visual style setting.
Custom Attributes
Using custom attributes not only benefits developers in creating attribute strings that better meet their own requirements, but also reduces the coupling between information and code by adding custom attribute information to Markdown text, thereby improving flexibility.
The basic process for creating custom attributes is:
-
Create custom AttributedStringKey
Create a data type that conforms to the Attributed protocol for each attribute that needs to be added.
-
Create custom AttributeScope and extend AttributeScopes
Create your own scope and add all custom attributes to it. In order to facilitate the use of custom attribute sets in situations where the scope needs to be specified, it is recommended to nest the required system framework scopes (SwiftUI, UIKit, AppKit) in the custom scope. And add the custom scope to AttributeScopes.
-
Extend AttributeDynamicLookup (supports dot syntax)
Create subscript methods that conform to the custom scope in AttributeDynamicLookup. Provide dynamic support for dot syntax and KeyPath.
Example 1: Creating an id attribute
In this example, we will create an attribute named “id”.
struct MyIDKey:AttributedStringKey {
typealias Value = Int // The type of the attribute's content. The type needs to be Hashable.
static var name: String = "id" // The name of the attribute stored in the attribute string.
}
extension AttributeScopes{
public struct MyScope:AttributeScope{
let id:MyIDKey // The name called by dot syntax.
let swiftUI:SwiftUIAttributes // Include the system framework SwiftUI in MyScope.
}
var myScope:MyScope.Type{
MyScope.self
}
}
extension AttributeDynamicLookup{
subscript<T>(dynamicMember keyPath:KeyPath<AttributeScopes.MyScope,T>) -> T where T:AttributedStringKey {
self[T.self]
}
}
Usage
var attribtedString = AttributedString("hello world")
attribtedString.id = 34
print(attribtedString)
// Output
hello world {
id = 34
}
Example 2: Creating an enumerated attribute and supporting Markdown parsing
If we want the attributes we create to be parsed in Markdown text, we need to make our custom attributes conform to CodeableAttributedStringKey
and MarkdownDecodableAttributedStringKye
.
// The data type of the custom attribute can be anything as long as it conforms to the necessary protocols.
enum PriorityKey: CodableAttributedStringKey, MarkdownDecodableAttributedStringKey {
public enum Priority: String, Codable { // To decode in Markdown, set the raw type to String and conform to Codable.
case low
case normal
case high
}
static var name: String = "priority"
typealias Value = Priority
}
extension AttributeScopes {
public struct MyScope: AttributeScope {
let id: MyIDKey
let priority: PriorityKey // Add the newly created Key to the custom Scope.
let swiftUI: SwiftUIAttributes
}
var myScope: MyScope.Type {
MyScope.self
}
}
In Markdown, use ^[text](attribute_name: attribute_value) to mark custom attributes.
Call
// When parsing custom properties in Markdown text, specify the Scope.
var attributedString = AttributedString(localized: "^[hello world](priority:'low')",including: \.myScope)
print(attributedString)
// Output
hello world {
priority = low
NSLanguage = en
}
Example 3: Creating Properties with Multiple Parameters
enum SizeKey:CodableAttributedStringKey,MarkdownDecodableAttributedStringKey{
public struct Size:Codable,Hashable{
let width:Double
let height:Double
}
static var name: String = "size"
typealias Value = Size
}
// Add to Scope
let size:SizeKey
Call
// Add multiple parameters within {}
let attributedString = AttributedString(localized: "^[hello world](size:{width:343.3,height:200.3},priority:'high')",including: \.myScope)
print(attributedString)
// Output
hello world {
priority = high
size = Size(width: 343.3, height: 200.3)
NSLanguage = en
}
In the WWDC 2021 New Formatter API article, there are also cases of using custom properties in Formatters.
Conclusion
Before AttributedString, most developers mainly used attributed strings to describe text display styles. With the ability to add custom properties in Markdown text, it is believed that developers will soon expand the use of AttributedString and apply it to more scenarios.
I hope this article is helpful to you.