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 basics
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 Paragraph
, Image
, or 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 Heading
or Image
is a block, while Emphasis
(italics) is inline.
This is a *simple* rich __text__ in __*Markdown*__ format.
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:
<document>
<paragraph>
<text>This is a </text>
<emph>
<text>simple</text>
</emph>
<text> rich </text>
<strong>
<text>text</text>
</strong>
<text> in </text>
<strong>
<emph>
<text>Markdown</text>
</emph>
</strong>
<text> format.</text>
</paragraph>
</document>
The CommonMark library will parse a Markdown document and give you a tree with this type of structure.
Markdown Composer
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.
@Composable
fun MDDocument(document: Document) {
MDBlockChildren(document)
}
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.
@Composable
fun MDBlockChildren(parent: Node) {
var child = parent.firstChild
while (child != null) {
when (child) {
is BlockQuote -> MDBlockQuote(child)
is ThematicBreak -> MDThematicBreak(child)
is Heading -> MDHeading(child)
is Paragraph -> MDParagraph(child)
is FencedCodeBlock -> MDFencedCodeBlock(child)
is IndentedCodeBlock -> MDIndentedCodeBlock(child)
is Image -> MDImage(child)
is BulletList -> MDBulletList(child)
is OrderedList -> MDOrderedList(child)
}
child = child.next
}
}
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 Text
, Emphasis
, StrongEmphasis
, and 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.
@Composable
fun MDHeading(heading: Heading) {
val style = when (heading.level) {
1 -> MaterialTheme.typography.h1
2 -> MaterialTheme.typography.h2
3 -> MaterialTheme.typography.h3
4 -> MaterialTheme.typography.h4
5 -> MaterialTheme.typography.h5
6 -> MaterialTheme.typography.h6
else -> {
// Not a header...
MDBlockChildren(heading)
return
}
}
val padding = if (heading.parent is Document) 8.dp else 0.dp
Box(paddingBottom = padding) {
val text = annotatedString {
appendMarkdownChildren(heading, MaterialTheme.colors)
}
MarkdownText(text, style)
}
}
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.
Image block
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.
@Composable
fun MDImage(image: Image) {
Box(modifier = Modifier.fillMaxWidth(), gravity = ContentGravity.Center) {
CoilImage(image.destination)
}
}
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.
Inline content
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.
fun AnnotatedString.Builder.appendMarkdownChildren(
parent: Node, colors: Colors) {
var child = parent.firstChild
while (child != null) {
when (child) {
is Paragraph -> appendMarkdownChildren(child, colors)
is Text -> append(child.literal)
is Image -> appendInlineContent(TAG_IMAGE_URL,
child.destination)
is Emphasis -> {
pushStyle(SpanStyle(fontStyle = FontStyle.Italic))
appendMarkdownChildren(child, colors)
pop()
}
is StrongEmphasis -> {
pushStyle(SpanStyle(fontWeight = FontWeight.Bold))
appendMarkdownChildren(child, colors)
pop()
}
is Code -> {
pushStyle(TextStyle(fontFamily =
FontFamily.Monospace).toSpanStyle())
append(child.literal)
pop()
}
is HardLineBreak -> {
append("\n")
}
is Link -> {
val underline = SpanStyle(colors.primary,
textDecoration = TextDecoration.Underline)
pushStyle(underline)
pushStringAnnotation(TAG_URL, child.destination)
appendMarkdownChildren(child, colors)
pop()
pop()
}
}
child = child.next
}
}
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 Heading
and 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.
Our 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.
MarkdownText()
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.
@Composable
fun MarkdownText(text: AnnotatedString,
style: TextStyle,
modifier: Modifier = Modifier) {
val uriHandler = UriHandlerAmbient.current
val layoutResult = remember {
mutableStateOf<TextLayoutResult?>(null)
}
Text(text = text,
modifier = modifier.tapGestureFilter { pos ->
layoutResult.value?.let {
val position = it.getOffsetForPosition(pos)
text.getStringAnnotations(position, position)
.firstOrNull()
?.let { sa ->
if (sa.tag == TAG_URL) {
uriHandler.openUri(sa.item)
}
}
}
},
style = style,
inlineContent = mapOf(
TAG_IMAGE_URL to InlineTextContent(
Placeholder(style.fontSize, style.fontSize,
PlaceholderVerticalAlign.Bottom)
) {
CoilImage(it, alignment = Alignment.Center)
}
),
onTextLayout = { layoutResult.value = it }
)
}
The MarkdownText()
function takes an AnnotatedString
and a base TextStyle
. The style parameter allows us to reuse this for both Heading
and 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 UriHandlerAmbient
.
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 CoilImage
.
Wrap up
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.