Rendering Markdown with Jetpack Compose
Most developers are familiar with the Markdown format. We use it for simple README files in our git repos or for writing blog posts. It's a powerful and yet simple format that allows us to write documents with rich text formatting, without the need for an advanced word editor.
The most common way to render Markdown content is through a build script that will transform the documents to static HTML and CSS content. This way, it can easily be rendered on all platforms.
But what if we would like to render it without having to use a WebView in an Android app? There are already libraries that will convert Markdown content to classical text spans and Views for Android, but in this post, we will explore how to do it using Jetpack Compose.
The image above is a screenshot from a small app I wrote using Jetpack Compose. As it turns out, writing this was a relatively simple task, at least when comparing to the traditional way of building Android UIs using Views and text spans.
Markdown is a straightforward format that doesn't have an official standard. However, the CommonMark project is currently working on a specification. For this post, I've been using their definition.
The result of parsing a document is a tree of nodes with the root called
Document, and it can have various children like
OrderedList. This tree is very similar to what a DOM tree in HTML would look like, but with more limited types of nodes.
Markdown nodes are one of two types, block or inline. For instance, a
Image is a block, while
Emphasis (italics) is inline.
Rich text, which can be bold, italic, links or inline code, are represented as the children of a block. For instance, the Markdown shown above can also be represented using the following XML tree:
The CommonMark library will parse a Markdown document and give you a tree with this type of structure.
What we want to do is to traverse our tree of Markdown nodes and call the appropriate composable function for each type of node. Each of these functions will call some regular Compose function and then iterate over its children.
The code above shows how the function looks for the top-level
Document node. Since this is the root of all Markdown documents, it won't do any actual rendering. It will instead just call the function for rendering all its children.
The code above is our function for traversing children of a Markdown node. It simply iterates over each node, determines the type, and then call the specific matching function.
Note that inline nodes, like
Code, are not called here. The reason is that inline content is just styled text and will be treated differently than block content. We will see how to do that later.
The function above shows how we render a Markdown heading. We start by determining the level of the
Heading and map that to a Material Design typography style. We render a Compose
Box with padding at the bottom, but only if this is a top-level node (i.e., the parent is the
Document). Inside the
Box, we will first construct an
AnnotatedString.Builder and render it using our
MarkdownText function. The call to
appendMarkdownChildren() will populate the
AnnotatedString with any additional styles. This function is explained in detail later in this post.
Images can exist as two types of nodes in Markdown. Either as a top-level block or as an inline image in a piece of text.
The function above shows how we render the top-level images. We create a
Box that will fill the width of its container and with gravity to centre. Next, we use the image loader Coil through the excellent wrapper by Chris Banes to render the actual image.
As mentioned earlier, the rendering of inline nodes are different than for block nodes. These are usually rich formatted text, but can also contain images. The way to solve this using Jetpack Compose is by constructing an
AnnotatedString.Builder where we add the text with specific styles and annotate parts of it for special handling once we render it.
We define the extension function shown above for
AnnotatedString.Builder for creating our rich text and any inline images. For most of the nodes, we push a style onto the builder, append a
String, and then pop the style. This way, we build upon an existing style, so this function can be used both for
Paragraph, which have different base styles.
For links, we also push an annotation to the string we append. This annotation has a tag and value, where the tag tells us the type of annotation and the value is the URL for the link. We use a similar for inline images, where we append annotated content without any string to be printed.
appendMarkdownChildren() function doesn't have to be a
@Composable function, since it doesn't do any actual rendering but simply appending content to a builder.
Also note that in Markdown, a
Paragraph can appear as a child to a block node (Heading, BulletList, etc.). We skip these nodes and call
appendMarkdownChildren() again with the
Paragraph as the parent parameter.
The final part is how we will render the
AnnotatedString that we constructed in
appendMarkdownChildren(). What we need is to render text that will handle varying styles, clickable links, and inline images. Fortunately, we have everything we need in Jetpack Compose, so our function will be relatively simple.
MarkdownText() function takes an
AnnotatedString and a base
TextStyle. The style parameter allows us to reuse this for both
Paragraph blocks. I have borrowed the code for this function from
ClickableText, which is currently part of the Jetpack Compose library.
We add an
onTap listener using the
tapGestureFilter modifier. This listener will use a
TextLayoutResult which contains information about the layout of the text on the screen. By calling
getOffsetForPosition() we get a position inside the
String in this text, and from there we can extract any annotation. When encountering an annotation, we check if the tag matches
TAG_URL and we now know it is a link that the user tapped on, so we can open it using the
The way the
inlineContent works is that it takes a map of tag and
InlineTextContent instance. The InlineTextContent lets us call a
@Composable function in the place where any of these tags occur. The
@Composable lambda passed to the
InlineTextContent constructor will receive the value for each tag, which in our case is the URL to the image that we appended in
appendMarkdownChildren() earlier. The
Placeholder parameter defined the size for the inline image, which in this case is a square the size of the current font. This box will be the container for our
Rendering Markdown using Jetpack Compose turned out to be much simpler than if we would have used the classic Android Views and text spans. The code is also much simpler to follow since we can keep everything in Kotlin instead of a mix of XML and Java/Kotlin code.
I've published the code for all of this on GitHub so you can experiment with all this yourself. Note that this is not a complete implementation according to the Markdown specification from CommonMark. Some node types are missing, and we're not handling some of the more complicated scenarios. Also, we haven't implemented any Markdown extensions (like tables).
This little experiment shows that Jetpack Compose is an excellent candidate for rendering more complex content and still having a dynamic implementation. Since Markdown is a common format for online content, we can use this method for rendering content from a headless CMS (like Contentful). While this is not the same as server-driven UI, it is a big step towards a solution for Android that allows us to control the layout without having to update the app.
Jetpack Compose is currently in alpha 2 (as of 2020-09-09) and still under development. I'll try to keep this post and the sample updated with the changes to Compose until it is stable.
If you enjoyed this article and have any questions, please reach out to me at @ErikHellman on Twitter. Many thanks to Joakim Carlgren, Nicola Corti and Adam Powell for helping me review this post and getting some great tips for improvements.