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 NSAttributedString
s, 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 {
}
"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