Instantiating "HTML-styled" NSAttributedString in a View's body produces weird behaviour

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!

  • Seems like an over-engineered issue.

  • Did you file a Feedback Assistant issue about this with a self-contained test project? If so, please post the FB ID here.

    There are a lot of moving parts here, so having a single bug report that's (a) easy to reproduce and (b) that can be moved between teams is going to be key to getting this investigated.

Add a Comment

Replies

for UITextView use:

func updateUIView(_ uiTextView: UITextView, context: Context) {
        DispatchQueue.main.async {
            let data = Data(self.html.utf8)
            if let attributedString = try? NSAttributedString(data: data, options: [.documentType: NSAttributedString.DocumentType.html, .characterEncoding: NSUTF8StringEncoding], documentAttributes: nil) {
                uiTextView.attributedText = attributedString
            }
        }
    }

and in SwiftUi body just sent html

data = """
your html with doctype, header, body etc
"""
AttributedText(data)