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.

4 (1) · 1 Review

Download materials
Save for later
Share
You are currently viewing page 3 of 4 of this article. Click here to view the first page.

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:

  1. Store the attributed string that will appear.
  2. Store the font and set a default value with Font.system(body).
  3. Ensure the body of the view has a standard SwiftUI.Text to render the stored attributedString.
  4. Set an initializer similar to SwiftUI.Text to take an attributed string as a parameter. Then, call the private annotateCustomAttributes(from:) with this string.
  5. Give a similar initializer a localization key, then create an attributed string from the localization file.
  6. Add a method to create and return a copy of the view with a modified font.
  7. 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:

  1. Loops on the available runs in the attributed string.
  2. Skips any runs that don’t have any value for customColor nor customStyle.
  3. Stores the range of the run for later use.
  4. Checks if the run has a value for customStyle.
  5. 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 style stronglyEmphasized.
  6. Otherwise, if the value is smallitalics, then do the same as above, except using lowercase characters with italic style emphasized instead.
  7. Checks without an else if customColor has a value.
  8. If the value is danger, sets the background color to red and the underline style to a yellow dashed line.
  9. 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.

The rendered Markdown using the new CustomText view

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.

Adding the new view file to the project

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.

Empty strings list with a plus at the top right corner

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.

The attributed string with custom attributes looks like plain text