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. By Ehab Amer.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
AttributedString Tutorial for Swift: Getting Started
30 mins
- Getting Started
- AttributedString vs. NSAttributedString
- Using Markdown
- Examining the Structure of an AttributedString
- Characters and Indices
- Runs
- Applying the Themes
- Defining Theme Styles
- Creating Custom Attributes
- Attribute Scopes
- Rendering Custom Attributes
- Saving Styled Strings
- Saving Custom Attributes
- Saving Fonts
- Where to Go From Here?
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.