AttributedString Tutorial for Swift: Getting Started [FREE]
source link: https://www.raywenderlich.com/29501177-attributedstring-tutorial-for-swift-getting-started
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
AttributedString Tutorial for Swift: Getting Started
Learn how to format text and create custom styles using iOS 15’s new AttributedString value type as you build a Markdown previewer in SwiftUI.
Version
Building great-looking apps doesn’t rely on just images — it also extends to text. Different styles in attributed strings can go a great distance to making information more appealing. In this tutorial, you’ll learn about the new AttributedString value type introduced in iOS 15 and macOS 12. You’ll also see how to leverage its capabilities, including formatting with Markdown, to do more with text in your apps.
This tutorial will cover:
- Differences between the new AttributedString and the older NSAttributedString that’s bridged from Objective-C.
- Formatting and styling an attributed string using Markdown.
- The structure of an attributed string and how to alter it.
- Creating and rendering custom attributes.
- Encoding and decoding an attributed string and its custom attributes.
Getting Started
Download the starter project by clicking Download Materials at the top or bottom of the tutorial.
The app you’ll build, Markdown Preview, allows you to type a basic text string that it then converts to an attributed string. Then, it saves this attributed string to a library of your creation.
Start by opening MarkdownPreview.xcodeproj in the starter folder. Build and run the app to see your starting point.
The first section of the screen allows you to choose a theme. A group of themes is already included in the project but won’t have any effect yet.
You’ll divide the work on this app into five parts:
- Converting a Markdown string to an attributed string.
- Applying the themes on the text without permanently changing its attributes.
- Creating custom attributes that can be part of your Markdown.
- Creating a text view that can render the new custom attributes.
- Saving your attributed string into a library.
AttributedString vs. NSAttributedString
Before you start working on the project, it’s worth knowing a few things about AttributedString in comparison with the older NSAttributedString. Specifically, it:
- Is a first-class Swift citizen and takes advantage of Swift features, similar to the differences between String and NSString.
- Is a value type, while the older NSAttributedString is a reference type.
- Conforms to
Codable
. You can directly encode and decode an AttributedString object along with its attributes just like working with a normal String. - Has the same character-counting behavior as String.
- Is fully localizable. You can even define styles in your text directly in the localization files!
- Most importantly, AttributedString has full support for Markdown.
Using Markdown
Markdown is a popular markup language for formatting text. It can format whole documents — not just paragraphs. You might be surprised to learn that all the books published here on raywenderlich.com are written entirely in Markdown. :]
Write a Markdown string in the Raw Markdown text field, and notice that the text appears as-is in the Rendered Markdown area. The Markdown attributes aren’t translated to style the text yet.
Open MarkdownView.swift in the Views group, then go to convertMarkdown(_:)
. This method handles converting your raw text to an AttributedString
. Your text isn’t treated as Markdown on its own if you use the standard initializer AttributedString(_:)
. Change the implementation of the method to:
// 1 guard var attributedString = try? AttributedString(markdown: string) else { // 2 return AttributedString(string) } // 3 printStringInfo(attributedString) // 4 return attributedString
The code you added does the following:
- Tries to convert the raw string to an attributed string using the initializer
AttributedString(markdown:)
. - If it fails, then it creates an attributed string using the default initializer without any Markdown styling.
- Prints some information about the attributed string. This method is currently empty. You’ll implement it in the next section.
- Returns the attributed string that succeeded in the Markdown initializer.
Build and run. Enter the same Markdown string you tried before in the Raw Markdown area, and see how it appears now:
Examining the Structure of an AttributedString
An AttributedString object consists of characters and you can count them just like a normal string. But it also consists of AttributedString.Runs
.
Runs are the parts of an AttributedString
that describe what style applies to which characters in the text. Each run consists of a range for a substring and the styles applied to it. If your text is plain and has no styles, then your attributed string will consist of only one run. If your AttributedString
uses a variety of styles, then it’ll be broken down into many runs. You’ll get deeper into runs shortly.
Characters and Indices
To get a better idea of what characters are, return to Views/MarkdownView.swift, then go to printStringInfo(_:)
. Implement it as follows:
// 1 print("The string has \(attributedString.characters.count) characters") // 2 let characters = attributedString.characters // 3 for char in characters { print(char) }
Here’s what’s happening in the code above:
- Print the number of characters in the attributed string.
- Create a variable holding the
AttributedString.CharacterView
that you’ll iterate over to get the value of each character separately. - Iterate over this collection and prints the value of the characters one by one.
Build and run. Enter this raw Markdown string to try it out:
This is **Bold text** and this is _italic_
You’ll see the output from printStringInfo(_:)
in Xcode’s console:
The string has 36 characters
The original string is 42 letters. But when treated as Markdown, the ** and _ characters that are part of the Markdown syntax are no longer part of the actual string. They became attributes or style, not content.
Scroll up a little in the log, and you’ll find that the previous log on the character count is 37. That happened right before you entered the last _, when the string was:
This is **Bold text** and this is _italic
You hadn’t entered the closing _ for the italic syntax, so AttributedString wasn’t treating this part as italic yet. Both opening and closing characters must be present, otherwise they’re considered part of the content.
Notice that the italic style isn’t applied, and the _ is part of the content in the attributed string.
Looking at the attributed string you entered above, you can describe it like this:
-
This is
uses regular style. -
Bold text
uses bold style. -
and this is
uses regular style. -
italic
would use italic style, had it been formatted with correct Markdown.
Those parts, in order, describe the string and its attributes. If you try to merge the first and the third parts, because they have the same style, you’ll end up with some complexity in defining the order of where the regular style should be applied.
This is very similar to runs and how they describe an attributed string.
Add the following at the end of printStringInfo(_:)
in Views/MarkdownView.swift:
// 1 print("The string has \(attributedString.runs.count) runs") // 2 let runs = attributedString.runs // 3 for run in runs { // 4 print(run) // 5 if let textStyle = run.inlinePresentationIntent { // 6 if textStyle.contains(.stronglyEmphasized) { print("Text is Bold") } if textStyle.contains(.emphasized) { print("Text is Italic") } // 7 } else { print("Text is Regular") } }
Here’s what’s happening in the code:
- Print the number of runs present in the attributed string.
- Create a variable for the collection of runs.
- Iterate over the collection.
- Print the description of the run.
- If the run has a value for the style
inlinePresentationIntent
, store its value for use. This value is anOptionSet
. It can hold one or more values where each value is represented by a bit. - If the stored value has
.stronglyEmphasized
as an option, then print “Text is Bold”. Or, if it has.emphasized
, print “Text is Italic”. - If no value is present, this means the text doesn’t have this style, so print “Text is Regular”.
Build and run. Enter the same text as before and check the log:
The string has 4 runs This is { NSPresentationIntent = [paragraph (id 1)] } Text is Regular Bold text { NSPresentationIntent = [paragraph (id 1)] NSInlinePresentationIntent = NSInlinePresentationIntent(rawValue: 2) } Text is Bold and this is { NSPresentationIntent = [paragraph (id 1)] } Text is Regular italic { NSPresentationIntent = [paragraph (id 1)] NSInlinePresentationIntent = NSInlinePresentationIntent(rawValue: 1) } Text is Italic
The number of runs is four, as you broke it down earlier. By checking the details of each run, you see they also have the same structure: the text and the available styles with their values.
Notice that the bold style has the value NSInlinePresentationIntent(rawValue: 2)
and the italic has NSInlinePresentationIntent(rawValue: 1)
. Notice the raw values. Enter the following string as the raw Markdown:
This is **Bold text** and this is _italic_ and this is **_both_**
This string has six runs. The final one has the NSInlinePresentationIntent
style value of NSInlinePresentationIntent(rawValue: 3)
. This means it’s italic and bold. Each option is represented by a bit. So, 2^0 + 2^1 = 3. It doesn’t have to be exclusively either bold or italic.
Applying the Themes
As you can see from the details of the runs, the attributed string you created didn’t specify any font name or size. It only had bold and italic styles.
An attributed string can define a font name, size, color and many other attributes. But in this app, you’ll build it differently. You want to have a set of themes you can choose from to apply to the string. Imagine you’re creating a text editor for Markdown, and you want the user to be able to choose a theme or style before printing the document. This means the theme won’t alter the original Markdown the user typed, but the chosen theme will determine how the final document looks.
Defining Theme Styles
iOS 15 provides a way to package a group of styles together so you can apply them in bulk on an attributed string. Do this by using AttributeContainer.
Go to TextTheme.swift in the Models group and add this computed property in the enumeration:
var attributeContainer: AttributeContainer { var container = AttributeContainer() switch self { case .menlo: container.font = .custom("Menlo", size: 17, relativeTo: .body) container.foregroundColor = .indigo case .times: container.font = .custom("Times New Roman", size: 17, relativeTo: .body) container.foregroundColor = UIColor.blue case .important: container.font = .custom("Courier New", size: 17, relativeTo: .body) container.backgroundColor = .yellow default: break } return container }
This creates a container with different attributes based on the current enumeration value. Each has a different font and a foreground or background color.
Next, go to Views/MarkdownView.swift and in convertMarkdown(_:)
, right before calling printStringInfo(_:)
, add this:
attributedString.mergeAttributes(selectedTheme.attributeContainer)
This tells the attributed string you created from the Markdown to merge its attributes with the ones from the theme’s attribute container.
Build and run. Change the theme a few times and see how the results change from one theme to another.
Creating Custom Attributes
So far, you’ve learned a lot about Swift’s new AttributedString
and how you can do different things with it. But you may have a few questions like:
- How do I define a new attribute?
- How can I combine existing styles to create a new effect on the text?
- What should the Markdown look like for a new attribute?
- How will the attributed string recognize the new attribute and how it will render?
Those are all good questions. The first thing to cover is that Markdown allows adding attributes directly, like this:
Some regular text then ^[text with an attribute](theAttributeKey: 'theValue') Some regular text then ^[text with two attributes] (theAttributeKey: 'theValue', otherAttributeKey: 'theOtherValue')
In the Markdown examples above, you have two custom attributes: theAttributeKey
and otherAttributeKey
. This is valid Markdown syntax, but for AttributedString to understand these attributes, you need to define an attribute scope.
Attribute Scopes
Attribute scopes help decode the attributes from Markdown or an encoded attributes string. Scopes are already defined for Foundation, UIKit, AppKit and SwiftUI.
When decoding the attributes, only one scope is used. The latter three include the Foundation’s scope inside them.
If you’re thinking: “Enough talking — show me how all that works!”, that’s completely understandable. :]
Create a new Swift file in the Models group named AttributeScopes.swift and add the following:
import SwiftUI public enum CustomStyleAttributes { public enum Value: String, Codable { case boldcaps, smallitalics } public static var name = "customStyle" } public enum CustomColorAttributes { public enum Value: String, Codable { case danger, highlight } public static var name = "customColor" }
These enumerations are the two custom attributes you’ll create. Each of them has a subtype enumeration with the allowed values and a string representation for its Key name that will appear in the Markdown:
-
CustomStyleAttributes
modifies the text to make it bold and uppercase or italic and lowercase. -
CustomColorAttributes
affects colors:-
danger
adds a red background and a yellow dashed underline to the text. -
highlight
adds a yellow background and a red dotted underline to the text.
-
Add the following in the same file:
// 1 public extension AttributeScopes { // 2 struct CustomAttributes: AttributeScope { let customStyle: CustomStyleAttributes let customColor: CustomColorAttributes // 3 let swiftUI: SwiftUIAttributes } // 4 var customAttributes: CustomAttributes.Type { CustomAttributes.self } } // 5 public extension AttributeDynamicLookup { subscript<T: AttributedStringKey>( dynamicMember keyPath: KeyPath<AttributeScopes.CustomAttributes, T> ) -> T { self[T.self] } }
AttributedString can understand the custom keys in Markdown by:
- Creating an extension to the existing
AttributeScopes
type. - Creating a new subtype to hold all the custom attributes you wish to use.
- Specifying a property that will refer to the existing attributes. Since this app is in SwiftUI, it’s
SwiftUIAttributes
. Alternatively, you can useFoundationAttributes
,UIKitAttributes
orAppKitAttributes
. Otherwise, existing attributes won’t be encoded and decoded. - Specifying a property that refers to the type itself.
- Specifying an extension on
AttributeDynamicLookup
with an override tosubscript(dynamicMember:)
. This helps you refer toCustomAttributes
directly as aKeyPath
.
Before you try it, the first enumerations you created must conform to CodableAttributedStringKey
since you’ll use them as Codable
properties in the attributed string and MarkdownDecodableAttributedStringKey
since you’ll use them from Markdown. Change the declaration of the enumerations to:
public enum CustomStyleAttributes: CodableAttributedStringKey, MarkdownDecodableAttributedStringKey {
public enum CustomColorAttributes: CodableAttributedStringKey, MarkdownDecodableAttributedStringKey {
Finally, in Views/MarkdownView.swift, change how you initialize the attributed string from Markdown:
guard var attributedString = try? AttributedString( markdown: string, including: AttributeScopes.CustomAttributes.self, options: AttributedString.MarkdownParsingOptions( allowsExtendedAttributes: true)) else { return AttributedString(string) }
Build and run. Enter the following Markdown to test your changes:
^[BoldCaps and Danger](customStyle: 'boldcaps', customColor: 'danger'), ^[SmallItalics and Highlighted](customStyle: 'smallitalics', customColor: 'highlight')
The attributed string looks like a normal string without any styles. But check the log, and you’ll see the following:
BoldCaps and Danger { customColor = danger customStyle = boldcaps NSPresentationIntent = [paragraph (id 1)] } . . SmallItalics and Highlighted { customStyle = smallitalics customColor = highlight NSPresentationIntent = [paragraph (id 1)] }
The attributed string has the correct custom attributes, so they were decoded correctly. What’s missing?
At this point, the UI doesn’t know what to do with those attributes. The information from the attributed string is stored properly but is completely unknown to SwiftUI.Text
, which is rendering the attributed string.
Rendering Custom Attributes
To properly render your custom attributes, you’ll need to create your own view to work with them. You might think you’ll need to draw the text yourself and take care of low-level rendering operations on the screen. You don’t need to worry about any of this! This class is a lot simpler than you might expect. All you need to do is transform the custom attributes to normal attributes that a standard Text view can understand, then use a Text view normally.
Create a new SwiftUI view in the Subviews group, and name it CustomText.swift. Replace the contents of the file with the following:
import SwiftUI public struct CustomText: View { // 1 private var attributedString: AttributedString // 2 private var font: Font = .system(.body) // 3 public var body: some View { Text(attributedString) } // 4 public init(_ attributedString: AttributedString) { self.attributedString = CustomText.annotateCustomAttributes(from: attributedString) } // 5 public init(_ localizedKey: String.LocalizationValue) { attributedString = CustomText.annotateCustomAttributes( from: AttributedString(localized: localizedKey, including: \.customAttributes)) } // 6 public func font(_ font: Font) -> CustomText { var selfText = self selfText.font = font return selfText } // 7 private static func annotateCustomAttributes(from source: AttributedString) -> AttributedString { var attrString = source return attrString } }
Going over the details of this new view — here, you:
- Store the attributed string that will appear.
- Store the font and set a default value with
Font.system(body)
. - Ensure the body of the view has a standard
SwiftUI.Text
to render the storedattributedString
. - Set an initializer similar to
SwiftUI.Text
to take an attributed string as a parameter. Then, call the privateannotateCustomAttributes(from:)
with this string. - Give a similar initializer a localization key, then create an attributed string from the localization file.
- Add a method to create and return a copy of the view with a modified font.
- Do nothing meaningful in this method — at least for now. This is where the real work will be. Currently, all it does is copy the parameter in a variable and return it. You’ll implement this shortly.
Next, in Views/MarkdownView.swift, change the view type that’s showing the converted Markdown from Text
to CustomText
. The contents of HStack should be:
CustomText(convertMarkdown(markdownString)) .multilineTextAlignment(.leading) .lineLimit(nil) .padding(.top, 4.0) Spacer()
Build and run. Make sure that you didn’t break anything. Nothing should look different from before.
Return to Subviews/CustomText.swift and add the following before the return
in annotateCustomAttributes(from:)
:
// 1 for run in attrString.runs { // 2 guard run.customColor != nil || run.customStyle != nil else { continue } // 3 let range = run.range // 4 if let value = run.customStyle { // 5 if value == .boldcaps { let uppercased = attrString[range].characters.map { $0.uppercased() }.joined() attrString.characters.replaceSubrange(range, with: uppercased) attrString[range].inlinePresentationIntent = .stronglyEmphasized // 6 } else if value == .smallitalics { let lowercased = attrString[range].characters.map { $0.lowercased() }.joined() attrString.characters.replaceSubrange(range, with: lowercased) attrString[range].inlinePresentationIntent = .emphasized } } // 7 if let value = run.customColor { // 8 if value == .danger { attrString[range].backgroundColor = .red attrString[range].underlineStyle = Text.LineStyle(pattern: .dash, color: .yellow) // 9 } else if value == .highlight { attrString[range].backgroundColor = .yellow attrString[range].underlineStyle = Text.LineStyle(pattern: .dot, color: .red) } } }
This might seem like a long block of code, but it’s actually quite simple. Here’s what it does:
- Loops on the available runs in the attributed string.
- Skips any runs that don’t have any value for
customColor
norcustomStyle
. - Stores the range of the run for later use.
- Checks if the run has a value for
customStyle
. - If that value is
boldcaps
, then creates a string from the characters in the range of the run and converts them to uppercase. Replace the text in the attributed string in the run’s range with the new uppercase characters, then applies the bold stylestronglyEmphasized
. - Otherwise, if the value is
smallitalics
, then do the same as above, except using lowercase characters with italic styleemphasized
instead. - Checks without an
else
ifcustomColor
has a value. - If the value is
danger
, sets the background color to red and the underline style to a yellow dashed line. - Otherwise, if the value is
highlight
, sets a yellow background and the underline style to a red dotted line.
Build and run. Try the same Markdown from the previous example.
Now your custom attributes are visible. Try choosing different themes. As expected, your themes changed the style of the text. Switching the themes also works.
Your attributed string isn’t altered when it appears. Your custom view copies it, so it can change safely, separate from the original.
Saving Styled Strings
The final part of your app is building the strings library. The app should show a list of all the saved attributed strings and use the Markdown previewer to add new strings.
First, change the navigation flow of the app to open a list first instead of the previewer. Open assets from this tutorial’s materials, and drag SavedStringsView.swift onto the Views group. Make sure to check Copy items if needed.
Then, go to MarkdownView.swift and add this new property at the top of the structure:
var dataSource: AttributedStringsDataSource<AttributedString>
In the preview code in the same file, change the creation of MarkdownView
to:
MarkdownView(dataSource: AttributedStringsDataSource())
Finally, in AppMain.swift show SavedStringsView instead of MarkdownView:
struct AppMain: App { var body: some Scene { WindowGroup { SavedStringsView(dataSource: AttributedStringsDataSource()) } } }
Build and run. Your app now opens directly on the Saved Strings screen, and it has a + at the top-right corner to open the Markdown Preview screen.
The listing screen passes the data source responsible for the persistence of the saved strings, but the preview screen doesn’t have any actions yet that allow you to save the attributed string you’re viewing.
To fix this, go to MarkdownView.swift and add the following to the structure, just above the definition of convertMarkdown(_:)
:
func saveEntry() { let originalAttributedString = convertMarkdown(markdownString) dataSource.save(originalAttributedString) cancelEntry() } func cancelEntry() { presentation.wrappedValue.dismiss() }
Add the following near the end of body
, after .navigationTitle("Markdown Preview")
:
.navigationBarItems( leading: Button(action: cancelEntry) { Text("Cancel") }, trailing: Button(action: saveEntry) { Text("Save") }.disabled(markdownString.isEmpty) )
Build and run. Add some values with the custom attributes, perhaps by copying and pasting the same Markdown you used earlier, then restart the app.
Saving Custom Attributes
When you saved your string, the formatting showed in the list — but when you relaunch the app, its styling is lost! There’s a simple reason for this. When you saved the string the first time, it was directly added to the data source array. But when you relaunched the app, the data source reloaded everything from a file. The attributed string with all its custom attributes was saved correctly in the file, but the custom attributes aren’t there the second time you launch the app.
The decoder in the data source doesn’t know anything about the attributes scope you created to encode and decode your custom attributes. And in a way, it shouldn’t care about that since it’s a generic decoder.
Saving Fonts
There’s also another issue. If you created some strings with a theme, you’ll notice that the font is also lost in the decoding process. This time, it’s not something you missed. It seems that SwiftUI.Font
doesn’t conform to Codable
, so its value isn’t stored.
To fix the first issue, you’ll need to wrap an attributed string in another type and configure the attributed string property with a Codable
configuration to consider the new scope. And for the second, you’ll just save the selected theme alongside the attributed string and reapply the theme when you add it to the list.
Create a new Swift file in the Models group named CustomAttributedString.swift. Add the following to it:
import SwiftUI struct CustomAttributedString: Codable, Identifiable, Hashable { // 1 func hash(into hasher: inout Hasher) { hasher.combine(textTheme) hasher.combine(attributedString) } // 2 var id: Int { attributedString.hashValue } // 3 var textTheme: TextTheme // 4 @CodableConfiguration(from: \.customAttributes) var attributedString = AttributedString() // 5 init(_ attString: AttributedString, theme: TextTheme) { attributedString = attString textTheme = theme } // 6 var themedString: AttributedString { var tempString = attributedString tempString.mergeAttributes(textTheme.attributeContainer) return tempString } }
Here’s what this does:
- Uses the hashing function to generate the hash of the current object by merging the hash values of its two stored properties.
- Defines a property,
id
, that returns the hash value.Identifiable
requires this property. - Sets a property for the theme of this string to reapply it whenever you need the string.
- Attaches a
CodableConfiguration
with theAttributeScopes.CustomAttributes
type that you created to handle decoding the custom attributes. - Adds an initializer for the new type.
- Sets a computed property for the attributed string that reapplies the theme.
Next, you’ll need to apply some changes to accommodate for the new type. In MarkdownView.swift, change the type of dataSource
to:
var dataSource: AttributedStringsDataSource<CustomAttributedString>
Then, change the implementation of saveEntry()
to:
func saveEntry() { let originalAttributedString = convertMarkdown(markdownString) let customAttributedString = CustomAttributedString( originalAttributedString, theme: selectedTheme) dataSource.save(customAttributedString) cancelEntry() }
This saves the new type that includes the original attributed string along with the theme instead of the attributed string alone.
Next, open SavedStringsView.swift and change the type of dataSource
to:
@ObservedObject var dataSource: AttributedStringsDataSource<CustomAttributedString>
Finally, in the body, change the loop that adds the strings in the list to:
ForEach(dataSource.currentEntries, id: \.id) { item in CustomText(item.themedString) .padding() }
This will use the new type in the listing page and recreate the theme attributes in the attributed string for presentation.
Uninstall the app from the simulator by long-tapping the app icon. Then, tap Remove app to delete the previously saved list.
Build and run. Save some strings with different themes, then restart the app.
You’ll see that all the styles are correctly applied in the list.
Where to Go From Here?
You can download the completed project files by clicking Download Materials at the top or bottom of the tutorial.
To learn more about AttributedString
, have a look at the developer documentation.
You can also check out Apple’s session from WWDC21 that introduces AttributedString
. The session references a sample project that illustrates overlapping attributes, localization and even rainbow text!
You should also check out SwiftUI Localization Tutorial for iOS: Getting Started to learn more about localization, pluralizations and grammar.
We hope you enjoyed this tutorial. If you have any questions or comments, please join the forum discussion below!
raywenderlich.com Weekly
The raywenderlich.com newsletter is the easiest way to stay up-to-date on everything you need to know as a mobile developer.
Get a weekly digest of our tutorials and courses, and receive a free in-depth email course as a bonus!
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK