SwiftUI AttributedString is not there yet
You should use it, but also be aware of some of its limitations
I’ve only recently started to use AttributedString, it’s been introduced with iOS 15 and as the Medium application also support iOS 14 I was not really looking into it.
However, as we write even more parts of the app in SwiftUI, and iOS 16 is approaching, I felt it was a good opportunity to finally look into it and replace some of our UILabel
representable (which display an NSAttributedString
in SwiftUI) by SwiftUI Text
+ AttributedString
.4
Our UILabel
representable come with a lot of small glitched & bugs as you have to do some manual sizing etc… we have quite a few layout issues depending of the version of SwiftUI you’re the app running on. While we can workaround or fixes most of those, it’s a lot of code and compromises.
Medium being a text heavy app, we have quite a lot of requirements when it come to text rendering, so while I was able to successfully use AttributedString
in a few places, I’ve also discovered some of its limitations.
On the surface, it looks like that AttributedString
is a 1:1 equivalent to NSAttributedString
. And sure you can actually pass attributes from and to those two classes, making all our existing style fit into it without much code changes. However, you have to understand that SwiftUI Text
will not render that many attributes. And on top of that some of the attributes are split between modifiers on Text
and attributes in AttributedString
. Coming from the AppKit / UIKit world this can feel weird as you usually don’t apply anything on UILabel
once you use an NSAttributedString
.
To understand what is available as SwiftUI AttributedString attributes you can look at AttributedScope.
The gist of it is that you can easily fit a UIKit world NSAttributedString
inside an AttributedString
, a convenience initialiser exist for that.
let attributedStringUIKit = NSAttributedString()
let attributedString = AttributedString(attributedStringUIKit)
This will work and your new AttributedString
will have all the attributes from the previous NSAttributedString
.
However, only the one available in the SwiftUI scope will render once you render your AttributedString
in a Text
like below
Text(attributedString)
If you had for example a NSParagraphStyle
with custom lineSpacing
or lineHeightMultiplier
, you can forget about it.
Instead what you can do is something like this
Text(attributedString).lineSpacing(attributedString.uiKit.paragraphStyle.lineSpacing)
This is the part where it get messy to me because it’s not clear what should be in the AttributedString
and what should be applied as a modifier on Text
. For now the only way to know what exactly will be rendered in a Text
+ AttributedString
without any additional modifiers is to look at AttributedScope
for SwiftUI.
And there you have it, it’s not that much yet. And iOS 16 doesn’t bring anything new to the table here.
The good new is that backgroundColor
is properly supported and will highlight the text correctly at the desired range.
And adding an attribute at a range using the subscript API of AttributedString
is really nice & easy.
attributedString[range].backgroundColor = .green
Prior to this the only way to highlight Text
in SwiftUI was to use the .background(Color(.green))
but then you would just get the whole rectangle painted. Not the actual text, lines, etc…
And it get even messier if you try to do any kind of factory for building and configuring Text. Because when using modifier like lineSpacing
it erase the Text
type to some View
. You can’t get your Text
back, so if in the output you would want to use nice Text
specific features such as concatenation Text(“”) + Text("")
, you can’t. So the only way is for to you to apply your lineSpacing
etc… outside of your factory.
Prior to using AttributedString
in SwiftUI, we’re using a nice helper to get the base of our style render for SwiftUI Text
public extension Text {
func styled<S: TextStyleProtocol>(by style: S) -> some View {
let attrs = style.attributes
let styledText = self.styledText(by: style)
let paragraphStyle = attrs[.paragraphStyle] as? NSParagraphStyle ?? NSParagraphStyle()
return styledText.lineSpacing(paragraphStyle.lineSpacing)
}
func styledText<S: TextStyleProtocol>(by style: S) -> Text {
let attrs = style.attributes
var styledText = self
if let f = attrs[.font] as? Font {
styledText = styledText.font(f)
} else if let f = attrs[.font] as? UIFont {
styledText = styledText.font(Font.custom(f.fontName, size: f.pointSize))
}
if let foreground = attrs[.foregroundColor] as? UIColor {
styledText = styledText.foregroundColor(Color(foreground))
}
if let kern = attrs[.kern] as? CGFloat {
styledText = styledText.kerning(kern)
}
return styledText
}
}
You can notice how the styleText can’t apply the .lineSpacing and we a separate function that erase the type for that.
With AttributedString
we still use those extension functions because we still need some modifiers outside of the AttributedString
. So we can have both the attributes supported by AttributedString
and the modifier supported on Text
.
Text(viewModel.attributedQuoteTextiOS15).styled(by: viewModel.quoteTextStyle)
And that’s a wrap, my hope is that this article will help some of you understand AttributedString
, what it can do and can’t. But also I hope that Apple will improve on it. I think having paragraph style supported within AttributedString
would be already a big plus, while also keeping the simpler modifier on Text
like it is today.
Thanks for reading 🚀
Bonus: Some interesting Twitter discussions around the subject that helped me find the motivation and information to write this article.