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
AttributedStringwrapper aroundNSAttributedStringandTextviews 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
NSAttributedStringinTextviews: 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
NSAttributedStringin aText, and - Transforming HTML text into a
NSAttributedStringand 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
@Bindingand working with a plainNSAttributedStringconstant. - Not storing the
NSAttributedStringanywhere, just passing it in the initializer. - Not even passing the
NSAttributedStringto the view, but instead directly passing the items array. - Storing the
NSAttributedStringoutside 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 Texts, 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))
}