Rendering Markdown in SwiftUI

WKWebView as a rich text renderer in SwiftUI


Tinkering with a SwiftUI chat app, I ran into a requirement for a Markdown renderer to display rich text and formatting in a message. The existing solutions for SwiftUI seemed to fall short. Native functionality is quite basic basic - you could do this to get some very basic formatting from a Markdown string:

Text(.init(textMessage))

Or use NSAttributedString, requiring a fair bit of manual setup to ensure styling looks right.

There are also 3rd party libs, but they all fall short for various reasons, including lack of customizability, limited feature sets, or low project activity. The most modern and powerful lib I found, MarkdownUI, worked great - until I discovered you couldn't make a multi-paragraph selection.

This seemed like a good time to experiment with WKWebView. HTML is well suited for displaying rich text and formatting content, and compared to other Markdown SwiftUI libs it has some advantages:

  • Clean browser default styling for all commonly rendered document elements such as headers, lists, tables, etc.
  • Accessible - text selection worked fully.
  • Flexible - being able to style HTML elements, add JS, and operate on the HTML string before rendering it allows for customization of chat messages with any functionality I'd want.
  • Performance - the library aims to get as close to O(N) complexity as possible when parsing Markdown.

MarkdownWebView

Below are the steps necessary for a fully functional WKWebView Markdown renderer, as well as a small explanation for each.

WKWebViews do not have intrinsic height, and I wanted the height of the Markdown view to be as large as the content within it. To handle this, we define an extension that will look for a .content div in the web view DOM and measure it, passing the height up to parent views via callback.

extension WKWebView {
    func getContentHeight(completion: @escaping (CGFloat) -> Void) {
        let javascript = "document.querySelector('.content').getBoundingClientRect().height"
        self.evaluateJavaScript(javascript) { (result, error) in
            DispatchQueue.main.async {
                completion(result as? CGFloat ?? 0)
            }
        }
    }
}

As the WKWebView is essentially acting as a standin for a normal SwiftUI view, we need to disable scroll and any scroll related functionality within it, delegating that responsibility to the SwiftUI views (e.g. ScrollView).

If the cursor was hovering over WKWebView when the scroll wheel was active, SwiftUI views would not behave as expected. WKWebView would capture scroll events and prevent them from propagating up to the ScrollView. To resolve this, we capture and handle the scroll event by defining a new class and overriding the scrollWheel method:

public class NoScrollWKWebView: WKWebView {
    public override func scrollWheel(with theEvent: NSEvent) {
        nextResponder?.scrollWheel(with: theEvent)
    }
}

Now scroll events are forwarded to the next responder in the chain, allowing the parent ScrollView to handle the event.

Alongside the above, we define the MarkdownRenderer NSViewRepresentable:

struct MarkdownRenderer: NSViewRepresentable {
    var markdownString: String
    var parser = MarkdownParser()

    @Binding var dynamicHeight: CGFloat

    func makeNSView(context: Context) -> WKWebView {
        let webView = NoScrollWKWebView()
        // Ensures the background is clear
        webView.setValue(false, forKey: "drawsBackground")
        webView.navigationDelegate = context.coordinator
        webView.loadHTMLString(self.getFullHtml(), baseURL: nil)
        return webView
    }

    func getFullHtml() -> String {
        let htmlContent = parser.html(from: markdownString)
        // keeping overflow: hidden will hide scrollbars effectively
        let customStyle = "<style>body { overflow: hidden; } .content { overflow: hidden; }</style>"
        return "<html><head>\(customStyle)</head><body><div class='content'>\(htmlContent)</div></body></html>"
    }

    func updateNSView(_ nsView: WKWebView, context: Context) {
        if let htmlContent = try? parser.html(from: markdownString) {
            nsView.loadHTMLString(self.getFullHtml(), baseURL: nil)
        }
    }

    class Coordinator: NSObject, WKNavigationDelegate {
        var parent: MarkdownRenderer

        init(parent: MarkdownRenderer) {
            self.parent = parent
        }

        func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
            // Triggers the function defined by the extension and exports the content height out of the web view to be used by SwiftUI
            webView.getContentHeight { height in
                self.parent.dynamicHeight = height
            }
        }
    }

    func makeCoordinator() -> Coordinator {
        Coordinator(parent: self)
    }
}

This will make use of the primitives we defined earlier, allowing us to use the view in SwiftUI that takes Markdown and renders a transparent WKWebView, sizing itself depending on the HTML content and ignoring all scroll events effectively making it a standalone Markdown rendering view:

Using the MarkdownWebView was now as simple as:

struct MarkdownWebView: View {
    @State private var dynamicHeight: CGFloat = .zero
    var markdownContent: String = ""

    var body: some View {
        MarkdownRenderer(markdownString: markdownContent, dynamicHeight: $dynamicHeight)
            .frame(height: dynamicHeight)
    }
}

It's content remains clickable and selectable, and is easily stylable with CSS.

 ScrollView {
    MarkdownWebView(markdownString: markdownContent)
 }