This episode is freely available thanks to the support of our subscribers

Subscribers get exclusive access to new and all previous subscriber-only episodes, video downloads, and 30% discount for team members. Become a Subscriber

We begin implementing an attributed string builder to replace the legacy infrastructure for rendering our books.

00:06 Today we're going to build something that helps us construct attributed strings. The reason for doing this is we currently render our books using an extensive technology stack: we write in Markdown, and a collection of scripts parses this, highlights Swift code, and adds in live views. This outputs Markdown again, which is converted into LaTeX by a Haskell tool called Haddock. Finally, we have a LaTeX stylesheet that outputs a PDF. In short, we're dealing with many dependencies, each with specific version requirements.

01:11 We want to replace this entire stack with a PDF generated by TextKit. TextKit renders NSAttributedStrings, so we need a way to generate those from a number of sources. In addition to Markdown strings, we also want to be able to embed a SwiftUI view, add footnotes, or lay elements out side by side.

02:12 Doing this ourselves will also be a lot of work, but at least we'll have control of the chain, and it should be a lot faster than our current system.

Attributed String Convertible

02:31 We want to convert a number of source types into NSAttributedString, so we can write a protocol that expresses the capability of this conversion:

protocol AttributedStringConvertible {
    func attributedString() -> NSAttributedString
}

03:02 At some point, we'll need something like an environment to pass down things like a stylesheet or a chapter number. By adding the environment as a parameter now, we'll save ourselves a lot of refactoring:

protocol AttributedStringConvertible {
    func attributedString(environment: Environment) -> NSAttributedString
}

03:31 For now, the environment only contains a dictionary of attributes:

struct Environment {
    var attributes: [NSAttributedString.Key: Any] = [:]
}

03:51 The first types we can easily conform to the protocol are String and AttributedString:

extension String: AttributedStringConvertible {
    func attributedString(environment: Environment) -> NSAttributedString {
        NSAttributedString(string: self, attributes: environment.attributes)
    }
}

extension AttributedString: AttributedStringConvertible {
    func attributedString(environment: Environment) -> NSAttributedString {
        NSAttributedString(self)
    }
}

04:35 As we can see above, the AttributedString conversion doesn't use the attributes from the environment, but the type allows us to use basic Markdown syntax for styling instead.

Example

04:49 Let's write a small example of how we want to construct an attributed string. We'd like to use the syntax afforded by a result builder. Inside the builder, we want to be able to combine strings and SwiftUI views. And it'd be nice to have ways of easily applying attributes — for example, to make a string bold:

@AttributedStringBuilder
var example: AttributedStringConvertible {
    "Hello, World!"
        .bold()
    EmbedSwiftUI {
        // insert some view
    }
    "Another String"
}

06:24 To start out, we strip the example down to its simplest version — a string built up from multiple expressions:

@AttributedStringBuilder
var example: AttributedStringConvertible {
    "Hello, World!"
    "Another String"
}

06:37 The first thing we'll need to make this work is the result builder type, AttributedStringBuilder. We implement the buildBlock method, which concatenates multiple expressions. Since the AttributedStringConvertible protocol doesn't have any associated types, we can use it as the type of the variadic parameter:

@resultBuilder
struct AttributedStringBuilder {
    static func buildBlock(_ components: AttributedStringConvertible...) -> some AttributedStringConvertible {

    }
}

07:45 The method needs to return some AttributedStringConvertible. This means we need a way to combine multiple values into one. However, this is quite tricky, because we can only do so by rendering the components into strings and concatenating those, but we can only render attributed strings if we have an environment.

08:11 Therefore, we need another conforming type that can store the information we have now for later processing. This type can store a build closure that takes an Environment and produces an NSAttributedString. For its conformance, it executes the build closure:

struct Build: AttributedStringConvertible {
    var build: (Environment) -> NSAttributedString

    func attributedString(environment: Environment) -> NSAttributedString {
        build(environment)
    }
}

10:02 In buildBlock, we can now construct a Build struct by providing a closure that takes an Environment value and that renders and concatenates the components:

@resultBuilder
struct AttributedStringBuilder {
    static func buildBlock(_ components: AttributedStringConvertible...) -> some AttributedStringConvertible {
        Build { environment in
            let result = NSMutableAttributedString()
            for component in components {
                result.append(component.attributedString(environment: environment))
            }
            return result
        }
    }
}

Preview

11:07 With this in place, the example expression compiles. And we can see the resulting string by creating a preview just for debugging:

#if DEBUG
@AttributedStringBuilder
var example: AttributedStringConvertible {
    "Hello, World!"
    "Another String"
}

import SwiftUI

struct DebugPreview: PreviewProvider {
    static var previews: some View {
        let attStr = example.attributedString(environment: .init())
        Text(AttributedString(attStr))
    }
}
#endif

12:21 We could also write tests, but the preview is useful because it immediately shows how our code works. This way, we can quickly see that we can also add a Markdown component:

@AttributedStringBuilder
var example: AttributedStringConvertible {
    "Hello, World!"
    "Another String"
    try! AttributedString(markdown: "Hello *world*")
}

Conditionals

12:46 It'd be nice if we could also write conditional statements in the attributed string builder:

@AttributedStringBuilder
var example: AttributedStringConvertible {
    "Hello, World!"
    if 2 > 1 {
        "\n"
    }
    try! AttributedString(markdown: "Hello *world*")
}

13:09 To make this work, we just have to implement the buildOptional method on the builder. We can reuse the Build struct for this method. In the build closure, we unwrap the optional component, and we render it if it's non-nil and add an empty string otherwise:

@resultBuilder
struct AttributedStringBuilder {
    // ...

    static func buildOptional<C: AttributedStringConvertible>(_ component: C?) -> some AttributedStringConvertible {
        Build { environment in
            component?.attributedString(environment: environment) ?? .init()
        }
    }
}

Next

14:04 In another episode, we might want to look at how we join strings. We currently use an empty string as the separator, but we'll want to differentiate this for block elements or lists.

14:53 To see if the attributes from the environment actually work, we can specify a different font:

@AttributedStringBuilder
var example: AttributedStringConvertible {
    "Hello, World!"
    try! AttributedString(markdown: "Hello *world*")
}

let sampleAttributes: [NSAttributedString.Key: Any] = [
    .font: NSFont(name: "Tiempos Text", size: 14)!
]

struct DebugPreview: PreviewProvider {
    static var previews: some View {
        let attStr = example.attributedString(environment: .init(attributes: sampleAttributes))
        Text(AttributedString(attStr))
    }
}

15:46 Now we can see that the string components pick up our custom attributes, but the Markdown string still uses default attributes. Ultimately, we'll want to streamline this by doing our own Markdown rendering, but this works fine for now.

References

Resources

  • Sample Code

    Written in Swift 5.7

  • Episode Video

    Become a subscriber to download episode videos.

In Collection

57 Episodes · 20h06min

See All Collections

Episode Details

Recent Episodes

See All

Unlock Full Access

Subscribe to Swift Talk

  • Watch All Episodes

    A new episode every week

  • icon-benefit-download Created with Sketch.

    Download Episodes

    Take Swift Talk with you when you're offline

  • Support Us

    With your help we can keep producing new episodes