SwiftUI Text with HTML via NSAttributedString
Reading time: 8 min
This recipe shows how to format content of a SwiftUI Text
with HTML via NSAttributedString
on any SwiftUI version.
The end result looks like this:
There are two solutions in this recipe:
- SwiftUI 3 (iOS 15, macOS 12) brings in a new
AttributedString
wrapper aroundNSAttributedString
andText
views work natively with it. There's some sample code at the end of the article that shows how to use it. - SwiftUI 1 and 2 (iOS 13-14, macOS 10.15-11) are surprisingly lacklustre when it comes to working with
NSAttributedString
inText
views: there's no default support for it, and even trying to include an attributed string directly in a View results in some weird memory issues. It's not all hopeless, though: read on to see a working solution for all SwiftUI versions.
Recipe for all versions
The solution isn't fully complete as it doesn't support all the HTML tags - but it gets the job done most of the time.
It also doesn't support hyperlinks straight away - to check out how that is done, use the Hyperlinks in SwiftUI Text recipe. The main difference is that this recipe works with SwiftUI Text views only, which can be a big advantage.
TL;DR: Check out this gist for the full code.
First we'll check out the solution that works, which:
- Supports most HTML formatting options,
- has no sizing or layout issues,
- uses a native
Text
, allowing you to use allText
-specific view modifiers.
Then, we'll take a look at a few other attempts that fail due to internal SwiftUI issues.
The working solution
OK, so the working solution was two parts to it:
- Displaying a
NSAttributedString
in aText
, and - Transforming HTML text into a
NSAttributedString
and ironing out a few details.
Displaying NSAttributedString in a Text
The first part breaks the NSAttributedString
into distinctly attributed substrings, producing and styling a Text
based on the its attributes. Then, it relies on Text
concatenation - the fact that you can use the +
operator on two Text
instances and SwiftUI will combine them into a new Text
, while taking care of layout and sizing:
var concatenatedText: Text {
Text("My") + Text(" text") // yep, this works
}
There is an implicit downside to this - the +
operator works only on Text
, and only some of its view modifiers actually return a Text
and not some View
. E.g, this doesn't work:
var concatenatedText: Text {
Text("My") + Text(" text").onTapGesture {
} // nope, as onTapGesture doesn't return a Text
}
This means that not all attributes can be applied to a Text
: e.g, the link
attribute is ignored as there's no way to detect a tap on a Text
and still return a Text
. However, it does cover virtually all the formatting from an attributed string that you'll normally need. Here's the code:
extension Text {
init(_ attributedString: NSAttributedString) {
self.init("") // initial, empty Text
// scan the attributed string for distinctly attributed regions
attributedString.enumerateAttributes(in: NSRange(location: 0, length: attributedString.length),
options: []) { (attrs, range, _) in
let string = attributedString.attributedSubstring(from: range).string
var text = Text(string)
// then, read applicable attributes and apply them to the Text
if let font = attrs[.font] as? UIFont {
// this takes care of the majority of formatting - text size, font family,
// font weight, if it's italic, etc.
text = text.font(.init(font))
}
if let color = attrs[.foregroundColor] as? UIColor {
text = text.foregroundColor(Color(color))
}
if let kern = attrs[.kern] as? CGFloat {
text = text.kerning(kern)
}
if #available(iOS 14.0, *) {
if let tracking = attrs[.tracking] as? CGFloat {
text = text.tracking(tracking)
}
}
if let strikethroughStyle = attrs[.strikethroughStyle] as? NSNumber,
strikethroughStyle != 0 {
if let strikethroughColor = (attrs[.strikethroughColor] as? UIColor) {
text = text.strikethrough(true, color: Color(strikethroughColor))
} else {
text = text.strikethrough(true)
}
}
if let underlineStyle = attrs[.underlineStyle] as? NSNumber,
underlineStyle != 0 {
if let underlineColor = (attrs[.underlineColor] as? UIColor) {
text = text.underline(true, color: Color(underlineColor))
} else {
text = text.underline(true)
}
}
if let baselineOffset = attrs[.baselineOffset] as? NSNumber {
text = text.baselineOffset(CGFloat(baselineOffset.floatValue))
}
// append the newly styled subtext to the rest of the text
self = self + text
}
}
}
If you play around with the resulting Text
instances, you'll find out that applying the font
view modifier doesn't work, i.e:
Text(myAttributedString)
.font(.system(size: 24)) // has no effect
On the other hand, other modifiers, such as bold
, italic
or foregroundColor
work as expected. This seems to be an internal SwiftUI inconsistency, but it's important to note it because of the next part.
Displaying HTML in Text
Swift can easily convert an HTML string into a NSAttributedString
and then we can feed that NSAttributedString
into the initializer from the previous section. The only thing left to sort out is how to set the font for the entire HTML text. To do this, we'll resort to a little trick - we'll embed the HTML-formatted string into a HTML document skeleton, and specify its styling via CSS:
extension Text {
init(html htmlString: String, // the HTML-formatted string
raw: Bool = false, // set to true if you don't want to embed in the doc skeleton
size: CGFloat? = nil, // optional document-wide text size
fontFamily: String = "-apple-system") { // optional document-wide font family
let fullHTML: String
if raw {
fullHTML = htmlString
} else {
var sizeCss = ""
if let size = size {
sizeCss = "font-size: \(size)px;"
}
fullHTML = """
<!doctype html>
<html>
<head>
<style>
body {
font-family: \(fontFamily);
\(sizeCss)
}
</style>
</head>
<body>
\(htmlString)
</body>
</html>
"""
}
let attributedString: NSAttributedString
if let data = fullHTML.data(using: .unicode),
let attrString = try? NSAttributedString(data: data,
options: [.documentType: NSAttributedString.DocumentType.html],
documentAttributes: nil) {
attributedString = attrString
} else {
attributedString = NSAttributedString()
}
self.init(attributedString) // uses the NSAttributedString initializer
}
}
And that's it. You can try the solution out with this code:
let htmlString = """
<h2>SwiftUI Text with HTML via NSAttributedString</h2>
<br/>
<p>This recipe shows how to <strong>format content of a SwiftUI <em>Text</em> with HTML via <em>NSAttributedString</em></strong> - sort of. The solution isn't fully complete as it doesn't support all the HTML tags, nor does it support hyperlinks (even though they're supported in attributed strings) - but it gets the job done most of the time.
<br/>
<p>The reason for its limitations is that SwiftUI is surprisingly lacklustre when it comes to working with <em>NSAttributedString</em> in <em>Text</em> views: it has no default support for it, and even trying to include an attributed string directly in a View results in some <font color="blue"><u>weird memory issues</u></font>.<br/>
<br/>
First we'll check out the <font color="blue"><u>solution that works</u></font>, which:<br/>
<ol>
<li>Supports most HTML formatting options</li>
<li>has no sizing or layout issues,</li>
<li>uses a native <em>Text</em>, allowing you to use all <em>Text</em>-specific view modifiers.</li>
</ol>
<br/>
Then, we'll take a look at a <font color="blue"><u>few other attempts</u></font> that fail due to internal SwiftUI issues.
"""
struct TestView: View {
var body: some View {
Text(html: htmlString, size: 16)
.padding()
}
}
Solutions that don't work
Wrapping UILabel in UIViewRepresentable
This solution involves, well, wrapping UILabel
in UIViewRepresentable
and then assigning the attributed string to its attributedText
property. Something like this:
struct AttributedText: UIViewRepresentable {
let attributedString: NSAttributedString
func makeUIView(context: Context) -> UILabel {
let label = UILabel()
// additional label config here
return label
}
func updateUIView(_ uiView: UILabel, context: Context) {
uiView.attributedText = attributedString
}
}
The problem with this approach is that the text doesn't break into multiple lines. I fixed that part, but the view still wouldn't size properly and had layout issues, especially when wrapped in a GeometryReader
. I was able to solve it for a particular use case, but couldn't find a solution that works in any layout, like the Text
-based solution does.
Using FlowLayoutView
This solution involves creating a custom view for every attributed string subrange and laying them out with a Flow Layout. The Flow Layout orders the view sequentially in a line and handles line breaks and wrapping automatically, so it seemed like the perfect candidate. At least until SwiftUI decided to interfere.
For whatever reason, SwiftUI's AttributeGraph
crashes when you store a NSAttributedString
in a View
. Consider this example:
struct AttributedView: View {
@Binding var attributedString: NSAttributedString
var items = [AttrStr]()
init(_ attributedString: Binding<NSAttributedString>) {
_attributedString = attributedString
attributedString.enumerateAttributes(in: NSRange(location: 0, length: attributedString.length), options: []) { (attrs, range, _) in
items.append(AttrStr(string: attributedString.attributedSubstring(from: range).string, attrs: attrs))
}
var body: some View {
FlowLayoutView(mode: .scrollable,
binding: $attributedString,
items: items) { item in
Text("doesn't really matter what goes here")
}
}
}
struct AttrStr: Hashable {
let id = UUID() // to make Hashable implementation easier
let string: String // the text
let attrs: [NSAttributedString.Key: Any] // attributes on that text
static func == (lhs: AttrView.AttrStr, rhs: AttrView.AttrStr) -> Bool {
lhs.id == rhs.id
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
}
If you run this sample, you'll get a series of AttributeGraph
errors, ending in a crash:
I tried working around this in several ways:
- Removing the
@Binding
and working with a plainNSAttributedString
constant. - Not storing the
NSAttributedString
anywhere, just passing it in the initializer. - Not even passing the
NSAttributedString
to the view, but instead directly passing the items array. - Storing the
NSAttributedString
outside of the entire app view hierarchy.
Now, if you pass a hardcoded attributed string to the app, it doesn't crash. However, if you plan on computing it somewhere in the code (as we do when transforming HTML string) or even read it as NSLocalizedString
, it crashes. I suspect there's a deeper issue that work, which also might be the reason why Apple hasn't delivered any native SwiftUI support for NSAttributedString
.
Luckily, the Text
solution works well enough most of the time, at least until the next version of SwiftUI provides us with the support we need (EDIT: The support is finally here in SwiftUI 3!)
SwiftUI 3 solution
SwiftUI 3 supports attributed string natively, as well as rendering them in Text
s, resulting in the following code:
if let data = fullHTML.data(using: .unicode),
let nsAttrString = try? NSAttributedString(data: data,
options: [.documentType: NSAttributedString.DocumentType.html],
documentAttributes: nil) {
Text(AttributedString(nsAttrString))
}