Finally we are going to get attributed strings as a native Swift value type. So lets see how we can update the example of my last article about attributed strings:
let string = "Name (whatever)"
var attributed = AttributedString(string)
let nameEnd = attributed.range(of: " (")?.lowerBound ?? attributed.endIndex
attributed[..<nameEnd].font = .body.bold()
Code language: Swift (swift)
Pretty neat. No more NSRange
to deal with, and we can access the font
attribute directly as a typed property. No more dealing with attribute values as Any
.
Of course we still want to separate the presentation from the meaning of our strings. So we can take our semantic enum from last time. We need to define our attribute key again, but this time as a type conforming to the AttributedStringKey
protocol. This requires a type for our value and a name:
enum Semantic: Hashable {
case name
}
enum SemanticAttributeKey: AttributedStringKey {
static let name = "semantic"
typealias Value = Semantic
}
Code language: Swift (swift)
Note that all the requirements from this protocol are static
, so its best to define this as an otherwise empty enum
so we cannot create an instance of that type, which is never needed.
Next we create our attributed string and find the range we want to highlight. No need for NSRange
any more. Using a subscript we can then assign our name attribute to the part of our string we want to highlight in a type-safe way:
attributed[..<nameEnd][SemanticAttributeKey.self] = .name
Code language: Swift (swift)
To display the string we can simply replace our semantic attribute with a given font attribute. For this we prepare an attribute container with our name attribute and another one with the font attribute as an replacement. Finally we create our string to display using the replacingAttributes(_:with:)
method:
var nameContainer = AttributeContainer()
nameContainer[SemanticAttributeKey.self] = .name
let boldContainer = AttributeContainer.font(.body.bold())
let displayString = attributed.replacingAttributes(nameContainer, with: boldContainer)
Code language: Swift (swift)
Would be nice to directly have a property for our semantic attribute, just as .font
provided by Apple. We could define a bunch of extensions on AttributedString
, AttributedSubstring
, AttributeContainer
and so on. But thanks to @dynamicMemberLookup
and key paths there is an easier way.
First we need to define an attribute scope. That is a simple struct conforming to the AttributeScope
protocol inside an extension of AttributeScopes
. There we also need to define a property that returns the type of our scope. Inside of our scope we simply add a let
property for each of our attribute keys.
We can even move our attribute key definitions inside the scope, as we won’t have to use the type name in subscripts any more.
Finally we define an extension on AttributeDynamicLookup
which provides a dynamicMember
subscript for a key path from our scope to any AttributedStringKey
.
extension AttributeScopes {
var myApp: MyAppAttributes.Type { MyAppAttributes.self }
struct MyAppAttributes: AttributeScope {
let semantic: SemanticAttributeKey
}
}
extension AttributeDynamicLookup {
subscript<T: AttributedStringKey>(dynamicMember keyPath: KeyPath<AttributeScopes.MyAppAttributes, T>) -> T {
self[T.self]
}
}
Code language: Swift (swift)
Note that AttributeDynamicLookup
is an empty enum, so our subscript can never actually be called. Sadly this leads to some warnings about calling never-returning functions, at least with the current (at the time of writing this) Xcode 13 beta 3.
With that in place we can directly access .semantic
on attributed strings or our attribute containers:
attributed[..<nameEnd].semantic = .name
attributed.replaceAttributes(
AttributeContainer().semantic(.name),
with: AttributeContainer().font(.body.bold())
)
Code language: Swift (swift)
If we have multiple semantic attributes we need to replace for display we don’t want to call replaceAttributes
or replacingAttributes
for each one to avoid unneccessary copies.
If we only need to set a single attribute as a replacement we can use transformingAttributes
:
let displayString = attributed.transformingAttributes(\.semantic) { semantic in
if semantic.value == .name {
semantic.replace(with: \.font, value: .body.bold())
}
}
Code language: Swift (swift)
If we want to replace each semantic attribute with a set of different attributes (just as the convenience initializer from last time) we again need to write a custom method that loops through the attributes and sets the new attributes:
extension AttributedString {
func replaceAttributes<T: AttributedStringKey>(_ keyPath: KeyPath<AttributeDynamicLookup, T>, replacements: [T.Value: AttributeContainer], default defaultContainer: AttributeContainer? = nil) -> AttributedString {
var result = self
for (value, range) in runs[keyPath] {
if let value = value, let attributes = replacements[value] ?? defaultContainer {
result[range].setAttributes(attributes)
}
}
return result
}
}
let display = attributed.replaceAttributes(
\.semantic,
replacements: [
.name: AttributeContainer().font(.body.bold())
]
)
Code language: Swift (swift)
Of course this only scratches the surface. There is a ton more that AttributedString
can do. For me the most interesting part probably is the integrated markdown support. Just watch the What’s New in Foundation talk from WWDC 2021.