iosswiftswiftuimobileuikit

Displaying HTML text in SwiftUI causing attribute cycles or crashes


I need to be able to display HTML tagged text with a SwiftUI text view. Following tutorials I came to this UIViewRepresentable solution using NSAttributedString.

struct HTMLText: UIViewRepresentable {
    var attributedText: NSAttributedString

    init(text: String) {
        self.attributedText = text.htmlToAttributedString
    }

    func makeUIView(context: Context) -> UITextView {
        let uiTextView = UITextView()

        uiTextView.backgroundColor = .red
        uiTextView.textContainerInset = .zero
        uiTextView.isEditable = false
        uiTextView.isScrollEnabled = false
        uiTextView.textContainer.lineFragmentPadding = 0

        return uiTextView
    }

    func updateUIView(_ uiTextView: UITextView, context: Context) {
        uiTextView.attributedText = attributedText
    }

}

extension String {
    var htmlToAttributedString: NSAttributedString {
        guard let data = data(using: .utf8) else {
            return NSAttributedString(string: self) }
        do {
            return try NSAttributedString(
                data: data,
                options: [.documentType: NSAttributedString.DocumentType.html,
                          .characterEncoding:String.Encoding.utf8.rawValue],
                documentAttributes: nil)
        } catch {
            return NSAttributedString(string: self)
        }
    }
}

While this works nicely for simple views, I have run into difficulty incorporating it into my app. Something to do with a NavigationStack causes cycles in the attribute graph: === AttributeGraph: cycle detected through attribute 96280 ===. Used in my app a few dozen of these messages are logged and no text field displayed. More fatally, GeometryReader used directly as a parent view seems to cause a SIGABRT, with a logging of precondition failure: setting value during update: 768.

This replication causes cycles, although only a few and the text is displayed:

struct ContentView: View {
    @State var showView: Bool = false
    
    var body: some View {
        NavigationStack {
            Button(action: {
                showView.toggle()
            }, label: {
                Text("Show view")
            })
            .navigationDestination(isPresented: $showView, destination: {
                ScrollableView()  // Putting the scroll view here directly seems to be fine
            })
        }
    }
}

struct ScrollableView: View {
    var body: some View {
        ScrollView {
            HTMLText(text: "hello <em> world </em>")
        }
    }
}

And this causes a complete crash.

struct Crash: View {
    var body: some View {
        GeometryReader { _ in
            HTMLText(text: "hello <em> world </em>")
        }
    }
}

The exact point where things go wrong is where the string data is attempted to be coded into the attributed string, even though it is wrapped in a try block, the error does not seem to be handled here at all. I've not been able to find other ways to display HTML text, do I have other options? What steps can I take to try debug an attribute graph?


Solution

  • As debug your code, I found out that when access htmlToAttributedString, you can see an internal function _UIHostingView.requestImmediateUpdate call. Base on its name, I think its job is to force UI reload immediately. Which will cause a cycle as show with this logging AttributeGraph: cycle detected through attribute. As you can see when put debug breakpoint in init of HTMLText:

    enter image description here

    A ScrollableView.body.getter will lead to another ScrollableView.body.getter.

    enter image description here

    To get rid of this problem, my suggestion is to put your htmlToAttributedString in a DispatchQueue.main.async:

    struct HTMLText: UIViewRepresentable {
        var text: String
    
        init(text: String) {
            self.text = text // instead of create NSAttributedString, just save a String
        }
    
        func makeUIView(context: Context) -> UITextView {
            let uiTextView = UITextView()
    
            uiTextView.backgroundColor = .red
            uiTextView.textContainerInset = .zero
            uiTextView.isEditable = false
            uiTextView.isScrollEnabled = false
            uiTextView.textContainer.lineFragmentPadding = 0
    
            return uiTextView
        }
    
        func updateUIView(_ uiTextView: UITextView, context: Context) {
            DispatchQueue.main.async { //<- get rid of cycle
                uiTextView.attributedText = self.text.htmlToAttributedString
            }
        }
    }