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)
}