SwiftUI Attributed Strings - Decoding and Applying Custom Remote Attributes with Markdown and Attribute Keys

SwiftUI Attributed Strings - Decoding and Applying Custom Remote Attributes with Markdown and Attribute Keys
💡
This is the next part of my previously published blog post on Attributed strings in iOS 15 - SwiftUI - Attributed Strings Improvements in iOS 15. The first post explains concepts that are needed to understand this post in detail. If you haven't read the previous post yet, I strongly recommend reading it before going through the current blog post.
💡
On the second look, I realized that this post has grown too big. If you directly want to take a look at the code without going through the text, please refer to this Gist. The Gist has a full source code you can copy-paste and see the preview in the Xcode canvas

One of the fancy attributed strings additions to iOS 15 has been a support for custom remote attributes with attribute keys via markdown format. How would you like if the server could define custom styles to strings in incoming JSON and the client would already know how to stylize them?

This is essentially what the new attributed strings API offers in iOS 15. Attributed strings with this new format look something like this,

^[string_to_decorate](attribute_key_1: attribute_value_1, attribute_key_2: attribute_value_2)

The iOS client already knows how to read attribute key-value pairs and how to decode them. This is what we're going to take a look at next.

Let's define a simple string with attribute key-value pairs coming from the server and we will see what client-side changes we need to make to decode and decorate strings with it.


let inputString = "^[Amsterdam](cityStyle: 'amsterdamStyle', cityColor: 'amsterdamColor') is a great city"

We define two attributes on the string "Amsterdam" - cityStyle with the value of amsterdamStyle and another attribute is, cityColor with the value of amsterdamColor. To read and apply these style requirements, we will make the following changes to our iOS app.

First, we will add two enums, one for each attribute. These enums will help us read attribute names and values as returned in the incoming markdown strings.


public enum CustomCityStyleAttributes: CodableAttributedStringKey, MarkdownDecodableAttributedStringKey {
    public enum Value: String, Codable {
        case amsterdamStyle
    }

    public static var name = "cityStyle"
}

public enum CustomCityColorAttributes: CodableAttributedStringKey, MarkdownDecodableAttributedStringKey {
    public enum Value: String, Codable {
        case amsterdamColor
    }

    public static var name = "cityColor"
}

Due to iOS requirements, we need to make both enums conform to CodableAttributedStringKey and MarkdownDecodableAttributedStringKey. Under value, we define one or more values associated with attributes. Since in this case, we have only one value for each attribute we define only one case for Value enum.

Next, we will combine these attributes under an umbrella attribute struct with the name CustomCityAttributes. This struct conforms to AttributeScope and is added as an extension on AttributeScopes.


public extension AttributeScopes {
    struct CustomCityAttributes: AttributeScope {
        let customCityStyle: CustomCityStyleAttributes
        let customColorStyle: CustomCityColorAttributes

        let swiftUI: SwiftUIAttributes
    }

    var customCityAttributes: CustomCityAttributes.Type  { CustomCityAttributes.self }
}


💡
You might have noticed an additional attribute of type SwiftUIAttributes. It represents built-in SwiftUI attributes in addition to our custom attributes. We can add as many custom attributes as we like under this parent attribute scope

We will use customCityAttributes as a key-path to decode the incoming string into an attributed string that iOS can parse to apply styles over.

Now that we have all the basic building blocks for parsing a string, we will use the incoming markdown string from server and custom attributes on the client side to convert it into a AttributedString struct.


let inputString = "^[Amsterdam](cityStyle: 'amsterdamStyle', cityColor: 'amsterdamColor') is a great city"

let customAttributedInputString = (try? AttributedString(
    markdown: inputString,
    including: AttributeScopes.CustomCityAttributes.self,
    options: AttributedString.MarkdownParsingOptions(allowsExtendedAttributes: true))) ?? AttributedString(inputString)
    
    
💡
We try to convert the incoming string into an AttributedString instance using custom CustomCityAttributes. If, for any case, the initialization fails, we will fall back to the regular AttributedString initializer. If the fallback happens, no custom attributes will be applied and the string will be displayed as it was received from the server

Creating a View to Display the Attributed String

Now that we have an attributed string, let's make a view that will show it on the UI. Out view will accept this attributed string through an initializer and the view's body will pass it to the Text instance to show in the app.

Our code will have two structures

  1. CustomAttributedStringTextView -  Main SwiftUI view to display the attributed string applying all the available attributes
  2. CustomAttributedStringTextViewModel - A view model to construct the instance of AttributedString after reading the markdown string

import SwiftUI

struct CustomAttributedStringTextViewModel {

    let customAttributedInputString: AttributedString

    init() {
    
        let inputString = "^[Amsterdam](cityStyle: 'amsterdamStyle', cityColor: 'amsterdamColor') is a great city and ^[regular rainbow style](rainbowStyle: 'regular') awesome city to live in ^[first half rainbow style](rainbowStyle: 'firstHalf') or ^[second half rainbow style](rainbowStyle: 'secondHalf')"

        self.customAttributedInputString = (try? AttributedString(
            markdown: inputString,
            including: AttributeScopes.CustomCityAttributes.self,
            options: AttributedString.MarkdownParsingOptions(allowsExtendedAttributes: true))) ?? AttributedString(inputString)
            
    }
}

struct CustomAttributedStringTextView: View {

    let customAttributedStringTextViewModel: CustomAttributedStringTextViewModel

    init() {
    
        self.customAttributedStringTextViewModel = CustomAttributedStringTextViewModel()
        
    }

    var body: some View {
        Text(customAttributedStringTextViewModel.customAttributedInputString)
            .padding()
            
    }
}

Output:

If you run the app, you will see the plain string without any markdown attributes surrounding it. They are stripped during the following call.


try? AttributedString(
    markdown: inputString,
    including: AttributeScopes.CustomCityAttributes.self,
    options: AttributedString.MarkdownParsingOptions(allowsExtendedAttributes: true))
    
    

This is a failable initializer. In case we aren't able to parse the incoming markdown, we fall back to using the default AttributedString initializer.


(try? AttributedString(
    markdown: inputString,
    including: AttributeScopes.CustomCityAttributes.self,
    options: AttributedString.MarkdownParsingOptions(allowsExtendedAttributes: true))) ?? AttributedString(inputString)
    
    

We still don't see any styles applied to incoming markdown. This is because we haven't defined any styles to apply to incoming markdown. When the app sees amsterdamStyle and amsterdamColor, it still doesn't know which special fonts and colors to apply to them. We will add them in the next step.

Adding Custom Styles to Attributed String as Dictated by Markdown

In this step, we will add a code to actually apply attributes and styles as dictated by the incoming markdown format. We will add customStyledAttributedString function which will inspect the incoming attributed string and apply styles to it as needed which is dictated by markdown syntax.


private func customStyledAttributedString(input: AttributedString) -> AttributedString {

    var output = input

    for run in output.runs {

        let customCityAttributes = run.customCityAttributes

        guard customCityAttributes.customCityStyle != nil || customCityAttributes.customColorStyle != nil else {
            continue
        }

        let range = run.range

        if let customCityStyle = customCityAttributes.customCityStyle {
            if customCityStyle == .amsterdamStyle {
                output[range].font = .largeTitle
            }
        }

        if let customColorStyle = customCityAttributes.customColorStyle {
            if customColorStyle == .amsterdamColor {
                output[range].foregroundColor = .orange
            }
        }
    }

    return output
}

Now that this function is ready, we will utilize it to apply attributes to incoming strings before showing it on the view. For reference, our input markdown string is,


"^[Amsterdam](cityStyle: 'amsterdamStyle', cityColor: 'amsterdamColor') is a great city"


var body: some View {
    Text(customStyledAttributedString(input: customAttributedStringTextViewModel.customAttributedInputString))
        .padding()
}

As you can see, as "Amsterdam" was wrapped inside custom style and color, we applied styles specific to these values through the code and we successfully bridged the gap between markdown and styles applied from the code.

💡
Now we can modify styles the way we want by making a single change in code or even introduce new styles for future app versions with a minimal change

Custom Attributes with Attribute Keys - Advanced Level

Now that our basic understanding of custom attributes and keys is clear, let's go on and add advanced attributes to the string.

In the next section, we will add a rainbow theme to our text. There are three versions of the rainbow theme. The markdown will dictate the rainbow style to apply and which specific style value to use. The app will accommodate this by adding code to recognize when the rainbow style is specified and will read which value to apply to substrings wrapped in the markdown.

Changing Input String

First, we will change our markdown string to specify which rainbow styles to use for which substrings.


let inputString = "^[Amsterdam](cityStyle: 'amsterdamStyle', cityColor: 'amsterdamColor') is a great city and ^[regular rainbow style](rainbowStyle: 'regular') awesome city to live in ^[first half rainbow style](rainbowStyle: 'firstHalf') or ^[second half rainbow style](rainbowStyle: 'secondHalf')"

Adding new Custom Rainbow Style Attribute

Next, we will add a new CustomRainbowStyleAttributes attribute conforming to CodableAttributedStringKey and MarkdownDecodableAttributedStringKey. The attribute name is rainbowStyle and there are 3 possible values associated with it.


public enum CustomRainbowStyleAttributes: CodableAttributedStringKey, MarkdownDecodableAttributedStringKey {
    public enum Value: String, Codable {
        case regular
        case firstHalf
        case secondHalf
    }

    public static var name = "rainbowStyle"
}

Now that we added a new style, we will also add it to existing CustomCityAttributes struct


struct CustomCityAttributes: AttributeScope {
    let customCityStyle: CustomCityStyleAttributes
    let customColorStyle: CustomCityColorAttributes
	
    // New styles
    let customRainbowStyle: CustomRainbowStyleAttributes

    let swiftUI: SwiftUIAttributes
}

The rest of the code in this struct remains as it is.

Adding Attributes for Rainbow Styles

If we run the app now, we still won't see new rainbow styles applied to the input strings marked with rainbow styles.

We will fix them by adding one more piece of code inside customStyledAttributedString method which will define where and how the rainbow styles should be applied. Markdown also specifies the value of this attribute so will apply different styles for each value.

Let's add this code at the end of customStyledAttributedString method.


private func customStyledAttributedString(input: AttributedString) -> AttributedString {

    var output = input

    for run in output.runs {

        .....
        ...
        .......
        ...

        let range = run.range

        .....
        ...

        if let customRainbowStyle = customCityAttributes.customRainbowStyle {

            let rainbowColorsCollection: [Color]

            switch customRainbowStyle {
            case .regular:
                rainbowColorsCollection = rainbowColorsCollectionFull
            case .firstHalf:
                rainbowColorsCollection = rainbowColorsCollectionFirstHalf
            case .secondHalf:
                rainbowColorsCollection = rainbowColorsCollectionSecondHalf
            }

            var integerIndex = 0
            for i in output[range].characters.indices {
                output[i..<output[range].characters.index(after: i)].foregroundColor = rainbowColorsCollection[integerIndex % rainbowColorsCollection.count]
                integerIndex += 1
            }
        }
    }

    return output
}

Since we are applying different ranges of rainbow colors to input strings based on the value, we will define three rainbow color collections to use.


private var rainbowColorsCollectionFull: [Color] {
    [.red, .orange, .yellow, .green, .blue, .indigo, .purple]
}

private var rainbowColorsCollectionFirstHalf: [Color] {
    [.red, .orange, .yellow]
}

private var rainbowColorsCollectionSecondHalf: [Color] {
    [.green, .blue, .indigo, .purple]
}

Rainbow Style Demo

Now that all the pieces are ready, let's run the app and see the applied styles in action.

And that's it! Once the basic infrastructure is set up, adding new styles or editing existing styles is just a few minutes of work.

Summary

So this was all about to know when it comes to applying custom remote attributes with markdown attribute keys. Although it was hard to understand this idea when presented in WWDC, I found a way to implement and play with it with real examples.

Now that I get how it works, I have a firm belief that this could be a pretty phenomenal idea when it comes to applying dynamic styles in the app, especially when everything from the content, business logic to design is moving to the server side. Have you got a chance to play with it yourself? How did you like it? Are there any improvements you would like to see in this API? Reach out to me on Twitter at @jayeshkawli with your thoughts.

💡
This blog post is a sequel to my first blog post on Attributed Strings Improvements in iOS 15. The first post explains the fundamentals of new attributed string APIs introduced in iOS 15 and this post explains advanced features on how to apply custom styles to attributed strings defined from markdown

References

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.
What’s new in Foundation - WWDC21 - Videos - Apple Developer
Discover how the latest updates to Foundation can help you improve your app’s localization and internationalization support. Find out...