In an attempt to expose the capabilities of NSAttributedString in combination with UITextView to the world of SwiftUI (specifically the ability to render basic HTML), I've wrapped UITextView in a UIViewRepresentable and used that in a custom SwiftUI View.
But I'm seeing some issues I can't really explain... So I would love to get a deeper understanding of what's going on. And possible also find a way to fix these issues in an appropriate way.
This is how it goes:
UIViewRepresentable wrapping UITextView to display NSAttributedString in the context of SwiftUI
import SwiftUI
struct AttributedText: UIViewRepresentable {
private let attributedString: NSAttributedString
init(_ attributedString: NSAttributedString) {
self.attributedString = attributedString
}
func makeUIView(context: Context) -> UITextView {
let uiTextView = UITextView()
// Make it transparent so that background Views can shine through.
uiTextView.backgroundColor = .clear
// For text visualisation only, no editing.
uiTextView.isEditable = false
// Make UITextView flex to available width, but require height to fit its content.
// Also disable scrolling so the UITextView will set its `intrinsicContentSize` to match its text content.
uiTextView.isScrollEnabled = false
uiTextView.setContentHuggingPriority(.defaultLow, for: .vertical)
uiTextView.setContentHuggingPriority(.defaultLow, for: .horizontal)
uiTextView.setContentCompressionResistancePriority(.required, for: .vertical)
uiTextView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
return uiTextView
}
func updateUIView(_ uiTextView: UITextView, context: Context) {
uiTextView.attributedText = attributedString
}
}
Used in a custom HTML SwiftUI View
import SwiftUI
struct HTML: View {
private let bodyText: String
init(_ bodyText: String) {
self.bodyText = bodyText
}
var body: some View {
AttributedText((try? NSAttributedString(
data: """
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<style type="text/css">
body {
font: -apple-system-body;
color: white;
}
</style>
</head>
<body>
\(bodyText)
</body>
</html>
""".data(using: .utf8)!,
options: [
.documentType: NSAttributedString.DocumentType.html,
.characterEncoding: NSUTF8StringEncoding,
],
documentAttributes: nil
)) ?? NSAttributedString(string: bodyText))
}
}
Put together in a simple SwiftUI app
import SwiftUI
struct ContentView: View {
var body: some View {
NavigationStack {
ScrollView {
HTML("""
<p>This is a paragraph</p>
<ul>
<li>List item one</li>
<li>List item two</li>
</ul>
""")
}
.navigationTitle("HTML in SwiftUI")
}
}
}
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
Now, when I build and run the simple SwiftUI app shown above, it renders just fine, but there is a lot of log entries similar to "=== AttributeGraph: cycle detected through attribute 24504 ===". In addition to that, the navigation title bugs out when I scroll up. It also seems like SwiftUI is not able to detect changes to the HTML
View, and does not re-evaluate its body if I re-create HTML
with a new bodyText
(even though its structural identity is preserved).
When I use Instruments to inspect SwiftUI View body invocations, I can see that initiating the inline HTML styled NSAttributedString in the HTML
View's body takes several milliseconds (not too surprising as it calls into WebKit stuff?), resulting in HTML.body
taking more than 15 milliseconds to complete. Which is a lot more than if i just instantiated a "pure" text string using e.g the NSAttributedString(string:)
initialiser.
The initial render also seem to call HTML.body
twice, a second time after calling the body of some View labeled "RootModifier" (Maybe the invocation of HTML.body
took too long, and SwiftUI tries again?).
Now, I acknowledge that all these signs yell "do not call computational heavy stuff inside a View's body!", but still, I would love to understand why SwiftUI complains about cycles in its AttributeGraph (as I can't really see any), and why SwiftUI does not re-evaluate HTML
's body if I re-create HTML
with a new bodyText
(as HTML
's initialiser is clearly called with a new and different bodyText
value).
I could also just completely drop the custom HTML
SwiftUI View, and just use the AttributedText
UIViewRepresentable directly. And then fully manage instances of HTML styled NSAttributedStrings in my model layer (and not instantiate them as part of some custom SwiftUI View). But that would remove some of the abstraction and readability of having a dedicated SwiftUI View for rendering HTML. So any suggestions on how to create such an abstraction/SwiftUI View would be greatly appreciated as well!