In the previous post, we introduced the new StringInterpolation design coming to Swift 5. In this second part, I’ll focus on one application of that new ExpressibleByStringInterpolation
, to make NSAttributedString
prettier.
The goal
One of the first application I thought about when seeing that new StringInterpolation design in Swift 5 was to make it easy to build an NSAttributedString
.
My goal was to be able to create an attributed string using a syntax like this1:
let username = "AliGator"
let str: AttrString = """
Hello \(username, .color(.red)), isn't this \("cool", .color(.blue), .oblique, .underline(.purple, .single))?
\(wrap: """
\(" Merry Xmas! ", .font(.systemFont(ofSize: 36)), .color(.red), .bgColor(.yellow))
\(image: #imageLiteral(resourceName: "santa.jpg"), scale: 0.2)
""", .alignment(.center))
Go there to \("learn more about String Interpolation", .link("https://github.com/apple/swift-evolution/blob/master/proposals/0228-fix-expressiblebystringinterpolation.md"), .underline(.blue, .single))!
"""
That big String uses the multi-line string literals syntax (new in Swift 4.0, in case you missed it) — and even goes as far as wrapping another multi-line String literal inside another (see the \(wrap: …)
segment)! — and contains interpolations to add some styling to parts of that big String… so a lot of new features of Swift coming together!
The result NSAttributedString
, once rendered in a UILabel
or NSTextView
, should then look like this:
☝️ Yes, that above with the text and image… is really just an NSAttributedString
(and not a complex view with layout or anything)! 🤯
First implementation
So how are we going to implement this? Well, similar to how we implemented GitHubComment
in part 1 of course!
So, let’s start with the declaration of the dedicated type first, before even tackling string interpolation:
struct AttrString {
let attributedString: NSAttributedString
}
extension AttrString: ExpressibleByStringLiteral {
init(stringLiteral: String) {
self.attributedString = NSAttributedString(string: stringLiteral)
}
}
extension AttrString: CustomStringConvertible {
var description: String {
return String(describing: self.attributedString)
}
}
Simple enough, right? That’s just a wrapper around NSAttributedString
.
Now, let’s add support for ExpressibleByStringInterpolation
that would allow both literals but also strings annotated with NSAttributedString
attributes:
extension AttrString: ExpressibleByStringInterpolation {
init(stringInterpolation: StringInterpolation) {
self.attributedString = NSAttributedString(attributedString: stringInterpolation.attributedString)
}
struct StringInterpolation: StringInterpolationProtocol {
var attributedString: NSMutableAttributedString
init(literalCapacity: Int, interpolationCount: Int) {
self.attributedString = NSMutableAttributedString()
}
func appendLiteral(_ literal: String) {
let astr = NSAttributedString(string: literal)
self.attributedString.append(astr)
}
func appendInterpolation(_ string: String, attributes: [NSAttributedString.Key: Any]) {
let astr = NSAttributedString(string: string, attributes: attributes)
self.attributedString.append(astr)
}
}
}
At that stage, we’re already able to use it that way to easily build an NSAttributedString
:
let user = "AliSoftware"
let str: AttrString = """
Hello \(user, attributes: [.foregroundColor: NSColor.blue])!
"""
That’s already nice as it is, right?
Adding styling convenience
But dealing with attributes as a dictionary [NAttributedString.Key: Any]
isn’t really nice. Especially since that Any
isn’t typed, and forces us to know the expected type of the value for each key…
So let’s make that nicer by creating a dedicated Style
type2 to help us building attributes dictionaries:
extension AttrString {
struct Style {
let attributes: [NSAttributedString.Key: Any]
static func font(_ font: NSFont) -> Style {
return Style(attributes: [.font: font])
}
static func color(_ color: NSColor) -> Style {
return Style(attributes: [.foregroundColor: color])
}
static func bgColor(_ color: NSColor) -> Style {
return Style(attributes: [.backgroundColor: color])
}
static func link(_ link: String) -> Style {
return .link(URL(string: link)!)
}
static func link(_ link: URL) -> Style {
return Style(attributes: [.link: link])
}
static let oblique = Style(attributes: [.obliqueness: 0.1])
static func underline(_ color: NSColor, _ style: NSUnderlineStyle) -> Style {
return Style(attributes: [
.underlineColor: color,
.underlineStyle: style.rawValue
])
}
static func alignment(_ alignment: NSTextAlignment) -> Style {
let ps = NSMutableParagraphStyle()
ps.alignment = alignment
return Style(attributes: [.paragraphStyle: ps])
}
}
}
This allows us to use Style.color(.blue)
to create a Style
wrapping [.foregroundColor: NSColor.blue]
easily.
But let’s not stop there then, and make our StringInterpolation
handle such Style
attributes now!
So the idea is to be able to write this:
let str: AttrString = """
Hello \(user, .color(.blue)), how do you like this?
"""
Wouldn’t it be nice? Well, let’s just implement the right appendInterpolation
for that!
extension AttrString.StringInterpolation {
func appendInterpolation(_ string: String, _ style: AttrString.Style) {
let astr = NSAttributedString(string: string, attributes: style.attributes)
self.attributedString.append(astr)
}
And here you have it! But… this only supports one Style
at a time then. Why not allow passing multiple Style
as parameters there? And to do so, instead of allowing a [Style]
parameter, forcing us to wrap the list of styles in brackets at call site… why not use variadic parameters here?
So instead of the previous implementation, let’s instead implement it that way:
extension AttrString.StringInterpolation {
func appendInterpolation(_ string: String, _ style: AttrString.Style...) {
var attrs: [NSAttributedString.Key: Any] = [:]
style.forEach { attrs.merge($0.attributes, uniquingKeysWith: {$1}) }
let astr = NSAttributedString(string: string, attributes: attrs)
self.attributedString.append(astr)
}
}
And now we can mix multiple styles!
let str: AttrString = """
Hello \(user, .color(.blue), .underline(.red, .single)), how do you like this?
"""
Supporting Images
Another capability of NSAttributedString
is to add images as part of the string, by using NSAttributedString(attachment: NSTextAttachment)
. To do that, it’s just a matter of implementing appendInterpolation(image: NSImage)
to use it.
For that feature, I wanted to add the ability to rescale the image as well. And because I tried all that in a macOS playground, which has its graphic context flipped, I also had to draw the image flipped. (Note that this detail might be different for the iOS implementation supporting UIImage
). So here’s what I came up with:
extension AttrString.StringInterpolation {
func appendInterpolation(image: NSImage, scale: CGFloat = 1.0) {
let attachment = NSTextAttachment()
let size = NSSize(
width: image.size.width * scale,
height: image.size.height * scale
)
attachment.image = NSImage(size: size, flipped: false, drawingHandler: { (rect: NSRect) -> Bool in
NSGraphicsContext.current?.cgContext.translateBy(x: 0, y: size.height)
NSGraphicsContext.current?.cgContext.scaleBy(x: 1, y: -1)
image.draw(in: rect)
return true
})
self.attributedString.append(NSAttributedString(attachment: attachment))
}
}
Wrapping styles in one another
Finally, sometimes, you want to apply a style to a large section of text, which itself might contain styles inside subsections of that text. Like "<b>Hello <i>world</i></b>"
in HTML where the whole section is bold but contains sub-parts in oblique.
Our API doesn’t support that yet, so let’s add it. The idea there is to be able to apply a list of Style...
to something that’s not just a String
but already an AttrString
with already existing attributes.
The implementation will be similar to appendInterpolation(_ string: String, _ style: Style...)
, but will mutate the AttrString.attributedString
to add attributes to it, intead of creating a brand new NSAttributedString
from a plain String
.
extension AttrString.StringInterpolation {
func appendInterpolation(wrap string: AttrString, _ style: AttrString.Style...) {
var attrs: [NSAttributedString.Key: Any] = [:]
style.forEach { attrs.merge($0.attributes, uniquingKeysWith: {$1}) }
let mas = NSMutableAttributedString(attributedString: string.attributedString)
let fullRange = NSRange(mas.string.startIndex..<mas.string.endIndex, in: mas.string)
mas.addAttributes(attrs, range: fullRange)
self.attributedString.append(mas)
}
}
And with all that, we have achieved our goal and are finally able to create an AttributedString using this single string with interpolations:
let username = "AliGator"
let str: AttrString = """
Hello \(username, .color(.red)), isn't this \("cool", .color(.blue), .oblique, .underline(.purple, .single))?
\(wrap: """
\(" Merry Xmas! ", .font(.systemFont(ofSize: 36)), .color(.red), .bgColor(.yellow))
\(image: #imageLiteral(resourceName: "santa.jpg"), scale: 0.2)
""", .alignment(.center))
Go there to \("learn more about String Interpolation", .link("https://github.com/apple/swift-evolution/blob/master/proposals/0228-fix-expressiblebystringinterpolation.md"), .underline(.blue, .single))!
"""
Conclusion
I hope that you enjoyed this series on StringInterpolation
and that it gave you a glimpse at the power brought by that new design.
You can download my Playground here1 with the full implementation for GitHubComment
(see part 1), AttrString
, and even some fun I tried with an simplistic implementation for RegEx
.
There are plenty more nice ideas around here to make use of that new ExpressibleByStringInterpolation
API coming in Swift 5 — including some from Erica Sadun’s blog here, here and here — so don’t hesitate to read more about it… and even have fun with it yourself!
-
For the code in that post & playground, you’ll need to use Swift 5, which is now shipped with Xcode 10.2. ↩ ↩2
-
Of course I’ve only implemented a limited list of styles there, for demo purposes. The idea would be to extend that
Style
type to support way more styles in the future, and ideally cover all possibleNSAttributedString.Key
that exists. ↩