SwiftUI on macOS: text, rich text, markdown, html and PDF views

This article has a source code supplement. To read that in parallel, open this link in a separate window or tab so you can follow this account with its full source code.

In my opening assessment of SwiftUI on macOS, I drew attention to its lack of support for three of the four key types of view used widely across macOS apps. Although this article doesn’t bring any good news that SwiftUI does have broader native support, it demonstrates how the situation isn’t quite as bleak.

This week, I set out to open and display as many different formats of text as possible, starting from plain text. In each case, to keep the task simple, the app is opening an example file with a set name stored within the app’s bundle.

Main App and Menu

Each of the six demonstration windows is added as a secondary window, leaving the template ContentView unchanged. Menu commands are added for a new Demos menu, as set out in the MenuCommands.swift source code. Each of its views then has its own source code file, based on one of two templates.

View template using SwiftUI

struct TextdemoView: View {

var text: String {
// …
return text
}

var body: some View {
ScrollView {
Text(text)
}
}
}

The most direct approach when using SwiftUI is to generate the rendered text into a variable that can then be displayed as Text in a ScrollView. Alternative idioms can be used when needed, but this is simplest and most direct.

View template using AppKit/WebKit

struct PDFKitView: NSViewRepresentable {
typealias NSViewType = PDFView

func makeNSView(context: NSViewRepresentableContext<PDFKitView>) -> PDFView {
let pdfView = PDFView()
// …
return pdfView
}

func updateNSView(_ uiView: PDFView, context: NSViewRepresentableContext<PDFKitView>) {
// no content needed
}
}

struct PDFdemoView: View {
var body: some View {
PDFKitView()
}
}

Incorporating an AppKit or WebKit view, such as PDFView here, requires that view to be created and set up in a makeNSView() function, with the returned view type aliased to NSViewType and the whole view NSViewRepresentable. That can then be called in the SwiftUI View, but can’t be set inside a ScrollView, which typically loses the NSView content inside. Thus scrolling behaviour depends on support in the AppKit/WebKit view. As these are static views, there’s no need to provide any code for the updateNSView() function.

Text view

This is the most straightforward, as all it has to do is read in the text file as a UTF-8 string and hand that to a scrolling Text view. I’m perhaps being a bit more explicitly pedantic than necessary in checking the safe return of that string.

Markdown view

This follows the same pattern, and is my first failure among these demos. Although Apple’s documentation doesn’t refer to markdown being rendered in a LocalizedStringKey, that appears to have been the case at one time, and has been recommended by others. Here, it simply renders the file as plain text, with its markup intact. I also attempted to render this as an AttributedString, but that simply ate all the formatting and markup without performing any rendering. Perhaps I’m missing something obvious here.

Rich Text direct view

I have used two different approaches to render Rich Text, this one reading the RTF file into an NSAttributedString first, then converting that to an AttributedString. While this renders the fonts and styles correctly, it ignores layout such as centring altogether, and I can’t see any way to preserve that in the AttributedString. However, this doesn’t rely on using AppKit.

The three remaining views all depend on invoking AppKit/WebKit NSViews to perform their rendering, so following the second view template given above.

HTML view

Rendering HTML into a WKWebView from WebKit is straightforward, and the resulting view is rendered excellently. It runs into problems, though, with navigation. Despite explicitly enabling navigation gestures, they’re obscure in macOS and are unable to return correctly from a remote link. To my surprise, closing this window and opening a new view also remained stuck on a remote link, and didn’t return the original view content generated in makeNSView(). In practice, this would require the WebView to implement its own navigation controls, making this considerably more complicated than shown here.

Rich Text AppKit view

Instead of implementing a Rich Text view using SwiftUI, more faithful rendering can be obtained using an NSTextView from AppKit. This is more complicated, and I don’t think I have got its settings correct here, even after lengthy experiments, as the resulting view isn’t scrollable and can’t be set in a SwiftUI ScrollView, making it essentially unnavigable and unusable.

PDF view

Following the AppKit template, this view works as expected. Here I have removed the PDFView setting to display the document in pages, because the demo uses a single-page format.

Summary

As it stands, SwiftUI supports a range of text views, although most appear to require workarounds to make them fully usable.
Plain text (SwiftUI) and PDF (AppKit) work as expected, and can be implemented simply.
Rich Text (SwiftUI) fails to render layout as expected, but is otherwise fully usable.
HTML (WebKit) works well apart from navigation, which requires additional controls to prevent the content from being marooned on another page.
Rich Text (AppKit) requires further workarounds before it’s usable.
Markdown (SwiftUI) requires further workarounds to be rendered as an AttributedString.
SwiftUI only supports one of these formats, plain text, for editing.