When your iOS app uses Markdown documents, why can’t we just transform them into natives view? What if instead of writing Swift UI code, we build a custom viewer app, which can even be run from Xcode Live Preview Canvas?
Just look at what you can do with it:
Demo showing live Markdown to SwiftUI teasing this article
In this story we are going to cover the following topics:
Parsing Markdown into an AST
The Resolver/Strategy Pattern
Building an UI from resolved nodes
In case you want to see the full library, checkout the GitHub repository CoolDown, our own Markdown parser @ techprimate.com, which also includes a work-in-progress library CDSwiftUIMapper.
Parsing Markdown into a AST Node Tree
It’s highly recommended that you read my previous article “Creating your own Markdown Parser from Scratch in Swift” as we will reuse concepts from there. Creating your own Markdown Parser from Scratch in Swift Markdown is used by many platforms on a daily basis. In this tutorial you will learn how to implement your own custom…link.medium.com
Anyway here is a short recap of the explained concepts:
Markdown documents consist out of blocks (separated by empty lines), which further consist out of fragments (separated by newline characters), which are made up of inline elements (such as text or bold words).
After parsing, the document can be represented as an abstract syntax tree (AST). The tree elements are from now one considered as nodes.
When converting a document from Markdown to SwiftUI, it goes through four stages: Styled Markdown (only for visual help) → Raw Markdown → AST/Node Tree → SwiftUI Views
Usually an example is easier to understand, so please take a look at the following one:
The styling is done by GitHub Gists. The actual raw document looks like the following:
Now when parsing the document using the markdown parser (in my case it’s CoolDown), the AST reprsentation looks like the following:
Perfect! Three of our four steps are quite simple to understand, now let’s get into the last step: converting the AST nodes into SwiftUI views.
The Resolver/Strategy Pattern
When parsing our tree, we have to think of a mapping function:
every single kind of node will have its own view representation. mapping: node → view
As an example, the list node from the previous code snippet, might be mapped to the following SwiftUI view code:
As you can see, each node is mapped to a view structure:
.list becomes a VStack view
.bullet becomes a HStack view, with a Text(“-“) as the first element
.text becomes a Text view
It is necessary to add a mapping function for every single node type, and manage it in an efficient way. The easiest way to do so is creating a mapper class, which takes an array of nodes as the input, manages a set of mapping functions and *outputs a SwiftUI view *structure.
For all you (aspiring) computer scientists out there, the applied software pattern is also called the Strategy pattern, as the function always has the same signature, but differs in its implementation. Strategy pattern - Wikipedia In computer programming, the strategy pattern (also known as the policy pattern) is a behavioral software design…en.wikipedia.org
In this article I will call them Resolver and they are defined like this:
You might be wondering, what is going on, so here a quick overview:
The mapping function takes a generic Node as an input. As we require nodes to subclass ASTNode that can be added as a generic constraint.
We don’t know what kind of view it will return therefore the output is a generic type Result.
Using typealias we can know use the keyword Resolver in our library
The different resolvers are managed in a mapper class:
The resolvers dictionary is a one-to-one map of different node type identifiers, to their corresponding mapping functions.
For this initial implementation, we decided to simply go with a String(describing: nodeType) as the identifier, which converts the Swift type into a String, e.g. String(describing: SwiftUI.Text.self) becomes “Text”. A much cleaner approach would be adding a static identifier to ASTNode which needs to be overwritten in every subclass. (“Hey Siri, remind me of static identifiers”).
During the implementation of this class we also hit the first limitation:
Which Result type should I use for the resolvers return value? One resolver might return SwiftUI.Text while others might even return a custom view. It is also not possible to use the super type View as it is a protocol and the compiler will start to complain:
Unfortunately I couldn’t find a more elegant solution (yet), other than type erasing. Therefore it uses AnyView which wraps any SwiftUI view into an untyped view structure.
A great feature of the addResolver function, is strong generic typing outside the library, such as this example mapper:
Building an UI from resolved nodes
At this point we have successfully parsed our document into a node structure, with a mapping utility ready for being filled with resolvers.
Our first resolver is the one for list which contains a list of nodes. A simple resolver to get to the desired VStack structure would be the following:
This is a great example of the so calledContainerNode, a node which contains more nested ones. We iterate each nested node mapper.resolve(node: node) which takes care of looking up the necessary resolver. In the class CDSwiftUIMapper mentioned above, you have probably noticed the fatalError(“not implemented”). This is a great time to implement them:
The function resolve takes the nodes set in the mapper during creation and resolves each one into an AnyView and combines them in an ForEach. If it misses a node resolver, it returns a warning text, as crashes should be avoided and are super hard to debug in Xcode Previews.
As a final step (to get to the original GIF at the beginning) add we add a new view MarkdownViewer which converts the input parameter text into nodes and after mapping wraps them in a ScrollView:
Combine everything together and you have created a markdown viewer in SwiftUI! 🚀
Preview of the markdown live editing demo
Isn’t this cool? It is possible to build SwiftUI apps using Markdown 🤯 How practical this approach is, well, you can decide that yourself.
Here a few thoughts on what’s next:
The framework CoolDown and its SwiftUI mapping library is still quite incomplete, therefore there is some work to do.
Erasing all typing still seems like a bad idea, especially when SwiftUI uses diffing mechanism to re-render only relevant parts of the UI. We will look further into it, to find a better solution.
My goal is adding a default resolver for every node type available, so the library eventually becomes plug-and-play to preview Markdown in an UI.
When working with interactive elements, such as a web link (e.g. follow @philprimes), we will experiment with mapping it into e.g. a Button which then on tap opens an associated Safari view, loading the URL.
Currently the MarkdownViewer parses the document every single time the view gets updated, which is very bad for the performance. One solution would be caching the parsed nodes in a cache (maybe even in the @Environment).
I am still experimenting with different resolvers. One major one is combining multiple TextNode nodes into a single one, so they work like a single line of text. Leave a star and/or watch the GitHub repository to stay updated ⭐️
If you would like to know more, checkout my other articles, follow me on Twitter and feel free to drop me a DM. You have a specific topic you want me to cover? Let me know! 😃