Rendering Markdown with Jetpack Compose

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.

Screenshot from sample Compose app rendering Markdown

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.
Simple Markdown example with emphasis and strong emphasis styling

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:

    <text>This is a </text>
    <text> rich </text>
    <text> in </text>
    <text> format.</text>
An XML tree representation of a Markdown 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.

fun MDDocument(document: Document) {
Composable function for the top level Document node

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.

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 =
Composable function for traversing all children of a Markdown node

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.

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...

    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)
Composable function for rendering a Markdown heading

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.

fun MDImage(image: Image) {
    Box(modifier = Modifier.fillMaxWidth(), gravity = ContentGravity.Center) {
Composable function for rendering top-level images

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, 
            is Emphasis -> {
                pushStyle(SpanStyle(fontStyle = FontStyle.Italic))
                appendMarkdownChildren(child, colors)
            is StrongEmphasis -> {
                pushStyle(SpanStyle(fontWeight = FontWeight.Bold))
                appendMarkdownChildren(child, colors)
            is Code -> {
                pushStyle(TextStyle(fontFamily = 
            is HardLineBreak -> {
            is Link -> {
                val underline = SpanStyle(colors.primary, 
                    textDecoration = TextDecoration.Underline)
                pushStringAnnotation(TAG_URL, child.destination)
                appendMarkdownChildren(child, colors)
        child =
Extension function for rendering inline Markdown content

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.


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.

fun MarkdownText(text: AnnotatedString, 
                 style: TextStyle, 
                 modifier: Modifier = Modifier) {
    val uriHandler = UriHandlerAmbient.current
    val layoutResult = remember { 

    Text(text = text,
        modifier = modifier.tapGestureFilter { pos ->
            layoutResult.value?.let { 
                val position = it.getOffsetForPosition(pos)
                text.getStringAnnotations(position, position)
                    ?.let { sa ->
                        if (sa.tag == TAG_URL) {
        style = style,
        inlineContent = mapOf(
            TAG_IMAGE_URL to InlineTextContent(
                Placeholder(style.fontSize, style.fontSize, 
            ) {
                CoilImage(it, alignment = Alignment.Center)
        onTextLayout = { layoutResult.value = it }
Our Composable function for rendering Markdown text

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.