Greetings, traveler!
Displaying HTML content in SwiftUI can be surprisingly tricky, especially if you want to go beyond simply rendering raw text. UIKit has long supported rich NSAttributedString
rendering with HTML, but SwiftUI still lacks a native way to render HTML while preserving full control over styling. In this post, we’ll look at a custom approach to bridge that gap using a simple SwiftUI wrapper.
We’ll also consider some caveats and limitations to be aware of before relying on this technique in production.
Use Case: Displaying a Legal Notice with Styled Links
Suppose you’re building a screen that needs to show a legal disclaimer with links to your privacy policy and terms of use. You want full control over the font, color, and appearance of the links — and you want it to fit naturally into a SwiftUI view hierarchy.
Here’s what the preview looks like:
HTMLText("""
By using this <b>app</b>, you agree to our <a href='https://example.com/privacy'>Privacy Policy</a> and <a href='https://example.com/terms'>Terms of Service</a>.
""")
.font(.systemFont(ofSize: 22))
.linkColor(.purple)
.boldLinks(true)
.underlinedLinks(true)
The HTMLText
View
The main component is HTMLText
, a SwiftUI View
that accepts an HTML string and renders it using an NSAttributedString
. Internally, it uses a repository to avoid reprocessing the HTML on every re-render.
struct HTMLText: View {
@State private var htmlRepository = HTMLRepository()
private let html: String
private var font: UIFont = .systemFont(ofSize: 17)
private var underlinedLinks: Bool = false
private var linkColor: UIColor = .blue
private var boldLinks: Bool = true
init(_ html: String) {
self.html = html
}
var body: some View {
Text(AttributedString(htmlRepository.htmlText))
.environment(\.openURL, OpenURLAction { url in
UIApplication.shared.open(url)
return .handled
})
.onAppear {
htmlRepository.configure(
html: html,
font: font,
underlinedLinks: underlinedLinks,
linkColor: linkColor,
boldLinks: boldLinks
)
}
}
}
Some styling:
extension HTMLText {
func font(_ font: UIFont) -> HTMLText {
var view = self
view.font = font
return view
}
func underlinedLinks(_ underlinedLinks: Bool) -> HTMLText {
var view = self
view.underlinedLinks = underlinedLinks
return view
}
func boldLinks(_ boldLinks: Bool) -> HTMLText {
var view = self
view.boldLinks = boldLinks
return view
}
func linkColor(_ linkColor: UIColor) -> HTMLText {
var view = self
view.linkColor = linkColor
return view
}
}
And our repository:
@Observable
private final class HTMLRepository {
var htmlText: NSAttributedString = .init()
private var isConfigured: Bool = false
func configure(
html: String,
font: UIFont,
underlinedLinks: Bool = false,
linkColor: UIColor = .blue,
boldLinks: Bool = true
) {
guard !isConfigured else { return }
isConfigured.toggle()
htmlText = html.htmlToAttributedString(
font: font,
underlinedLinks: underlinedLinks,
linkColor: linkColor,
boldLinks: boldLinks
)
}
}
Customizing Link Appearance in Attributed Strings
To gain fine-grained control over how links and fonts appear in our parsed HTML, we extend NSMutableAttributedString
with two utility methods: decorateLinks
and setFontFace
.
extension String {
func htmlToAttributedString(
font: UIFont,
underlinedLinks: Bool,
linkColor: UIColor,
boldLinks: Bool
) -> NSAttributedString {
guard
let data = self.data(using: .utf8),
let attributedString = try? NSMutableAttributedString(
data: data,
options: [
.documentType: NSAttributedString.DocumentType.html,
.characterEncoding: String.Encoding.utf8.rawValue
],
documentAttributes: nil
) else {
return .init()
}
attributedString.setFontFace(
font: font
)
attributedString.decorateLinks(
linkColor: linkColor,
isUnderlined: underlinedLinks,
isBold: boldLinks,
font: font
)
return attributedString
}
}
extension NSMutableAttributedString {
func decorateLinks(
linkColor: UIColor,
isUnderlined: Bool,
isBold: Bool,
font: UIFont
) {
enumerateAttribute(
.link,
in: NSRange(location: .zero, length: self.length),
options: []
) { value, range, _ in
if value != nil {
var attributes: [NSAttributedString.Key: Any] = [
.foregroundColor: linkColor
]
if !isUnderlined {
attributes[.underlineStyle] = NSUnderlineStyle().rawValue
}
if isBold {
let boldFont = UIFont(
descriptor: font.fontDescriptor.withSymbolicTraits(.traitBold) ?? font.fontDescriptor,
size: font.pointSize
)
attributes[.font] = boldFont
}
addAttributes(attributes, range: range)
}
}
}
func setFontFace(
font: UIFont,
color: UIColor? = nil
) {
enumerateAttribute(
.font,
in: NSRange(location: .zero, length: self.length)
) { value, range, _ in
if let fontValue = value as? UIFont,
let newFontDescriptor = fontValue
.fontDescriptor
.withFamily(font.familyName)
.withSymbolicTraits(fontValue.fontDescriptor.symbolicTraits) {
let newFont = UIFont(
descriptor: newFontDescriptor,
size: font.pointSize
)
removeAttribute(.font, range: range)
addAttribute(.font, value: newFont, range: range)
if let color = color {
removeAttribute(
.foregroundColor,
range: range
)
addAttribute(
.foregroundColor,
value: color,
range: range
)
}
}
}
}
}
Let’s walk through what each of them does.
decorateLinks(...)
This method is responsible for customizing the appearance of links in the attributed string. It works by enumerating all .link
attributes and applying additional formatting to the ranges where links are found.
enumerateAttribute(.link, in: NSRange(location: .zero, length: self.length), options: []) { value, range, _ in
...
}
Within the loop:
- We first check that a
.link
attribute is present (value != nil
). - We prepare a dictionary of attributes to apply. At minimum, we set the desired link color via
.foregroundColor
. - If link underlining is not desired (
isUnderlined == false
), we explicitly set.underlineStyle
to0
to remove the default underline. - If
isBold
istrue
, we attempt to create a bold version of the current font:- We reuse the font’s existing descriptor and apply
.traitBold
. - If bold conversion fails (e.g. the font doesn’t support bold), we fall back to the original descriptor.
- We reuse the font’s existing descriptor and apply
- Finally, all of these new attributes are applied to the range using
addAttributes
.
This approach ensures that we can independently control whether links are underlined, what color they use, and whether they appear bold — something that is not always straightforward using only HTML styling.
setFontFace(…)
This method is used to normalize the font used throughout the attributed string, typically to override the default font styles applied by the HTML parser.
It works by enumerating all .font
attributes in the string and rebuilding them with a specified base font.
enumerateAttribute(.font, in: NSRange(location: .zero, length: self.length)) { value, range, _ in
...
}
For each font:
- We extract the symbolic traits (e.g. bold, italic) from the original font and apply them to a new font that shares the specified
font.familyName
and point size. - This allows us to enforce a consistent font family while preserving individual style traits like bold or italic.
- If a color override is provided, we remove any existing
.foregroundColor
attribute and replace it with the specified color.
The result is a more predictable and uniform text appearance that still respects any inline emphasis (like bold or italic) defined in the original HTML.
Together, these two methods give you control over how HTML-rendered text looks and behaves — something especially useful when integrating styled legal disclaimers, help texts, or other rich content into SwiftUI views.
What’s Good About This Approach
- Full Control Over Styling: You can define link color, underline style, boldness, and base font.
- Encapsulation: The
HTMLText
view is self-contained and works like any other SwiftUI view. - UIKit Interop: Leverages
NSAttributedString
‘s HTML support without needing a customUIViewRepresentable
.
Alternatives
If you need full HTML rendering with complex content or advanced interaction, WKWebView
or UITextView
are still the most capable tools — at the cost of performance and layout flexibility in SwiftUI. For simpler use cases, this HTMLText
wrapper strikes a good balance.
Conclusion
This approach offers a clean way to integrate styled HTML into SwiftUI views without relying on a WebView
or on a UITextView
. It gives you decent control over fonts and links and integrates naturally into the declarative SwiftUI world.
However, it’s best suited for lightweight use cases — like legal notices, onboarding screens, or brief HTML descriptions — rather than full document rendering.
Full code:
import SwiftUI
#Preview {
HTMLText("By using this <b>app</b>, you agree to our <a href='https://example.com/privacy'>Privacy Policy</a> and <a href='https://example.com/terms'>Terms of Service</a>.")
.font(.systemFont(ofSize: 22))
.linkColor(.purple)
.boldLinks(true)
.underlinedLinks(true)
}
struct HTMLText: View {
@State private var htmlRepository = HTMLTRepository()
private let html: String
private var font: UIFont = .systemFont(ofSize: 17)
private var underlinedLinks: Bool = false
private var linkColor: UIColor = .blue
private var boldLinks: Bool = true
init(_ html: String) {
self.html = html
}
var body: some View {
Text(AttributedString(htmlRepository.htmlText))
.environment(\.openURL, OpenURLAction { url in
UIApplication.shared.open(url)
return .handled
})
.onAppear {
htmlRepository.configure(
html: html,
font: font,
underlinedLinks: underlinedLinks,
linkColor: linkColor,
boldLinks: boldLinks
)
}
}
}
extension HTMLText {
func font(_ font: UIFont) -> HTMLText {
var view = self
view.font = font
return view
}
func underlinedLinks(_ underlinedLinks: Bool) -> HTMLText {
var view = self
view.underlinedLinks = underlinedLinks
return view
}
func boldLinks(_ boldLinks: Bool) -> HTMLText {
var view = self
view.boldLinks = boldLinks
return view
}
func linkColor(_ linkColor: UIColor) -> HTMLText {
var view = self
view.linkColor = linkColor
return view
}
}
@Observable
private final class HTMLRepository {
var htmlText: NSAttributedString = .init()
private var isConfigured: Bool = false
func configure(
html: String,
font: UIFont,
underlinedLinks: Bool = false,
linkColor: UIColor = .blue,
boldLinks: Bool = true
) {
guard !isConfigured else { return }
isConfigured.toggle()
htmlText = html.htmlToAttributedString(
font: font,
underlinedLinks: underlinedLinks,
linkColor: linkColor,
boldLinks: boldLinks
)
}
}
extension String {
func htmlToAttributedString(
font: UIFont,
underlinedLinks: Bool,
linkColor: UIColor,
boldLinks: Bool
) -> NSAttributedString {
guard
let data = self.data(using: .utf8),
let attributedString = try? NSMutableAttributedString(
data: data,
options: [
.documentType: NSAttributedString.DocumentType.html,
.characterEncoding: String.Encoding.utf8.rawValue
],
documentAttributes: nil
) else {
return .init()
}
attributedString.setFontFace(
font: font
)
attributedString.decorateLinks(
linkColor: linkColor,
isUnderlined: underlinedLinks,
isBold: boldLinks,
font: font
)
return attributedString
}
}
extension NSMutableAttributedString {
func decorateLinks(
linkColor: UIColor,
isUnderlined: Bool,
isBold: Bool,
font: UIFont
) {
enumerateAttribute(
.link,
in: NSRange(location: .zero, length: self.length),
options: []
) { value, range, _ in
if value != nil {
var attributes: [NSAttributedString.Key: Any] = [
.foregroundColor: linkColor
]
if !isUnderlined {
attributes[.underlineStyle] = NSUnderlineStyle().rawValue
}
if isBold {
let boldFont = UIFont(
descriptor: font.fontDescriptor.withSymbolicTraits(.traitBold) ?? font.fontDescriptor,
size: font.pointSize
)
attributes[.font] = boldFont
}
addAttributes(attributes, range: range)
}
}
}
func setFontFace(
font: UIFont,
color: UIColor? = nil
) {
enumerateAttribute(
.font,
in: NSRange(location: .zero, length: self.length)
) { value, range, _ in
if let fontValue = value as? UIFont,
let newFontDescriptor = fontValue
.fontDescriptor
.withFamily(font.familyName)
.withSymbolicTraits(fontValue.fontDescriptor.symbolicTraits) {
let newFont = UIFont(
descriptor: newFontDescriptor,
size: font.pointSize
)
removeAttribute(.font, range: range)
addAttribute(.font, value: newFont, range: range)
if let color {
removeAttribute(
.foregroundColor,
range: range
)
addAttribute(
.foregroundColor,
value: color,
range: range
)
}
}
}
}
}
If you enjoyed this article, please feel free to follow me on my social media:
It might be interesting:
- Simplifying Data Access in SwiftUI with @dynamicMemberLookup
- Three Practical Tools for Managing References and Detecting Memory Leaks in Swift
- Paging with Peek: Three Ways to Implement Paginated Scroll in SwiftUI
- Conditional View Modifiers in SwiftUI: Convenience and Caveats
- Modular Form Validation in SwiftUI