Reading time: 8 min

This recipe shows how to format content of a SwiftUI Text with HTML via NSAttributedString. 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.

The reason for its limitations is that SwiftUI is surprisingly lacklustre when it comes to working with NSAttributedString in Text views: it has no default support for it, and even trying to include an attributed string directly in a View results in some weird memory issues.

The end result looks like this:

Screenshot%202021-05-26%20at%2014.24.29

TL;DR: Check out this gist for the full code.

First we'll check out the solution that works, which:

  1. Supports most HTML formatting options,
  2. has no sizing or layout issues,
  3. uses a native Text, allowing you to use all Text-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:

  1. Displaying a NSAttributedString in a Text, and
  2. 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:

Screenshot%202021-05-26%20at%2011.51.47

I tried working around this in several ways:

  • Removing the @Binding and working with a plain NSAttributedString 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.

Next Post Previous Post