SUBSCRIBE NOW
avatar
I always learn something just by skimming it that makes me want to bookmark the issue now and dig deeper later
SUBSCRIBE NOW
avatar
Keep up the good work with the newsletter 💪 I really enjoy it
SUBSCRIBE NOW
avatar
Dispatch is a must read for Android devs today and my go-to for keeping up with all things Jetpack Compose
SUBSCRIBE NOW
avatar
Dispatch has been my go-to resource as it's packed with useful information while being fun at the same time
SUBSCRIBE NOW
avatar
The content is light, fun, and still useful. I especially appreciate the small tips that are in each issue
SUBSCRIBE NOW
avatar
I truly love this newsletter ❤️‍🔥 Spot on content and I know there's a lot of effort that goes behind it
SUBSCRIBE NOW
avatar
Thanks for taking the time and energy to do it so well
JetpackCompose.app's Newsletter
avatar
I always learn something just by skimming it that makes me want to bookmark the issue now and dig deeper later
JetpackCompose.app's Newsletter
avatar
Keep up the good work with the newsletter 💪 I really enjoy it
JetpackCompose.app's Newsletter
avatar
Dispatch is a must read for Android devs today and my go-to for keeping up with all things Jetpack Compose
JetpackCompose.app's Newsletter
avatar
Dispatch has been my go-to resource as it's packed with useful information while being fun at the same time
JetpackCompose.app's Newsletter
avatar
The content is light, fun, and still useful. I especially appreciate the small tips that are in each issue
JetpackCompose.app's Newsletter
avatar
I truly love this newsletter ❤️‍🔥 Spot on content and I know there's a lot of effort that goes behind it
JetpackCompose.app's Newsletter
avatar
Thanks for taking the time and energy to do it so well

Markdown Composer

Author: Erik Hellman

Component capable of handing the markdown text that's passed to it. It uses CommonMarks for it's rendering. These functions will render a tree of Markdown nodes parsed with CommonMark.

To use this, you need the following two dependencies:

implementation "com.atlassian.commonmark:commonmark:0.15.2"
implementation "dev.chrisbanes.accompanist:accompanist-coil:0.2.0"

The following is an example of how to use this component:

val parser = Parser.builder().build()
val root = parser.parse(MIXED_MD) as Document
val markdownComposer = MarkdownComposer()

MarkdownComposerTheme {
    MDDocument(root)
}

package se.hellsoft.markdowncomposer
import android.util.Log
import androidx.compose.foundation.Box
import androidx.compose.foundation.ContentGravity
import androidx.compose.foundation.Text
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.material.Colors
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.gesture.tapGestureFilter
import androidx.compose.ui.platform.UriHandlerAmbient
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.annotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import dev.chrisbanes.accompanist.coil.CoilImage
import org.commonmark.node.BlockQuote
import org.commonmark.node.BulletList
import org.commonmark.node.Code
import org.commonmark.node.Document
import org.commonmark.node.Emphasis
import org.commonmark.node.FencedCodeBlock
import org.commonmark.node.HardLineBreak
import org.commonmark.node.Heading
import org.commonmark.node.Image
import org.commonmark.node.IndentedCodeBlock
import org.commonmark.node.Link
import org.commonmark.node.ListBlock
import org.commonmark.node.Node
import org.commonmark.node.OrderedList
import org.commonmark.node.Paragraph
import org.commonmark.node.StrongEmphasis
import org.commonmark.node.Text
import org.commonmark.node.ThematicBreak
/**
* These functions will render a tree of Markdown nodes parsed with CommonMark.
* Images will be rendered using Chris Banes Accompanist library (which uses Coil)
*
* To use this, you need the following two dependencies:
* implementation "com.atlassian.commonmark:commonmark:0.15.2"
* implementation "dev.chrisbanes.accompanist:accompanist-coil:0.2.0"
*
* The following is an example of how to use this:
* ```
* val parser = Parser.builder().build()
* val root = parser.parse(MIXED_MD) as Document
* val markdownComposer = MarkdownComposer()
*
* MarkdownComposerTheme {
* MDDocument(root)
* }
* ```
*/
private const val TAG_URL = "url"
private const val TAG_IMAGE_URL = "imageUrl"
@Composable
fun MDDocument(document: Document) {
MDBlockChildren(document)
}
@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 -> {
// Invalid header...
MDBlockChildren(heading)
return
}
}
val padding = if (heading.parent is Document) 8.dp else 0.dp
Box(paddingBottom = padding) {
val text = annotatedString {
inlineChildren(heading, this, MaterialTheme.colors)
}
MarkdownText(text, style)
}
}
@Composable
fun MDParagraph(paragraph: Paragraph) {
if (paragraph.firstChild is Image && paragraph.firstChild == paragraph.lastChild) {
// Paragraph with single image
MDImage(paragraph.firstChild as Image)
} else {
val padding = if (paragraph.parent is Document) 8.dp else 0.dp
Box(paddingBottom = padding) {
val styledText = annotatedString {
pushStyle(MaterialTheme.typography.body1.toSpanStyle())
inlineChildren(paragraph, this, MaterialTheme.colors)
pop()
}
MarkdownText(styledText, MaterialTheme.typography.body1)
}
}
}
@Composable
fun MDImage(image: Image) {
Box(modifier = Modifier.fillMaxWidth(), gravity = ContentGravity.Center) {
CoilImage(data = image.destination)
}
}
@Composable
fun MDBulletList(bulletList: BulletList) {
val marker = bulletList.bulletMarker
MDListItems(bulletList) {
val text = annotatedString {
pushStyle(MaterialTheme.typography.body1.toSpanStyle())
append("$marker ")
inlineChildren(it, this, MaterialTheme.colors)
pop()
}
MarkdownText(text, MaterialTheme.typography.body1)
}
}
@Composable
fun MDOrderedList(orderedList: OrderedList) {
var number = orderedList.startNumber
val delimiter = orderedList.delimiter
MDListItems(orderedList) {
val text = annotatedString {
pushStyle(MaterialTheme.typography.body1.toSpanStyle())
append("${number++}$delimiter ")
inlineChildren(it, this, MaterialTheme.colors)
pop()
}
MarkdownText(text, MaterialTheme.typography.body1)
}
}
@Composable
fun MDListItems(listBlock: ListBlock, item: @Composable (node: Node) -> Unit) {
val bottom = if (listBlock.parent is Document) 8.dp else 0.dp
val start = if (listBlock.parent is Document) 0.dp else 8.dp
Box(paddingBottom = bottom, paddingStart = start) {
var listItem = listBlock.firstChild
while (listItem != null) {
var child = listItem.firstChild
while (child != null) {
when (child) {
is BulletList -> MDBulletList(child)
is OrderedList -> MDOrderedList(child)
else -> item(child)
}
child = child.next
}
listItem = listItem.next
}
}
}
@Composable
fun MDBlockQuote(blockQuote: BlockQuote) {
val color = MaterialTheme.colors.onBackground
Box(modifier = Modifier.drawBehind {
drawLine(
color = color,
strokeWidth = 2f,
start = Offset(12.dp.value, 0f),
end = Offset(12.dp.value, size.height)
)
}, paddingStart = 16.dp, paddingTop = 4.dp, paddingBottom = 4.dp) {
val text = annotatedString {
pushStyle(
MaterialTheme.typography.body1.toSpanStyle()
.plus(SpanStyle(fontStyle = FontStyle.Italic))
)
inlineChildren(blockQuote, this, MaterialTheme.colors)
pop()
}
Text(text)
}
}
@Composable
fun MDFencedCodeBlock(fencedCodeBlock: FencedCodeBlock) {
val padding = if (fencedCodeBlock.parent is Document) 8.dp else 0.dp
Box(paddingBottom = padding, paddingStart = 8.dp) {
Text(
text = fencedCodeBlock.literal,
style = TextStyle(fontFamily = FontFamily.Monospace)
)
}
}
@Composable
fun MDIndentedCodeBlock(indentedCodeBlock: IndentedCodeBlock) {
// Ignored
}
@Composable
fun MDThematicBreak(thematicBreak: ThematicBreak) {
//Ignored
}
@Composable
fun MDBlockChildren(parent: Node) {
var child = parent.firstChild
while (child != null) {
when (child) {
is Document -> MDDocument(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
}
}
fun inlineChildren(
parent: Node,
annotatedString: AnnotatedString.Builder,
colors: Colors
) {
var child = parent.firstChild
while (child != null) {
when (child) {
is Paragraph -> inlineChildren(
child,
annotatedString,
colors
)
is Text -> annotatedString.append(child.literal)
is Image -> {
annotatedString.appendInlineContent(TAG_IMAGE_URL, child.destination)
}
is Emphasis -> {
annotatedString.pushStyle(SpanStyle(fontStyle = FontStyle.Italic))
inlineChildren(
child,
annotatedString,
colors
)
annotatedString.pop()
}
is StrongEmphasis -> {
annotatedString.pushStyle(SpanStyle(fontWeight = FontWeight.Bold))
inlineChildren(
child,
annotatedString,
colors
)
annotatedString.pop()
}
is Code -> {
annotatedString.pushStyle(TextStyle(fontFamily = FontFamily.Monospace).toSpanStyle())
annotatedString.append(child.literal)
annotatedString.pop()
}
is HardLineBreak -> {
annotatedString.pushStyle(TextStyle(fontFamily = FontFamily.Monospace).toSpanStyle())
annotatedString.append("\n")
annotatedString.pop()
}
is Link -> {
annotatedString.pushStyle(
SpanStyle(
color = colors.primary,
textDecoration = TextDecoration.Underline
)
)
annotatedString.pushStringAnnotation(TAG_URL, child.destination)
inlineChildren(
child,
annotatedString,
colors
)
annotatedString.pop()
annotatedString.pop()
}
}
child = child.next
}
}
@Composable
fun MarkdownText(text: AnnotatedString, style: TextStyle) {
val uriHandler = UriHandlerAmbient.current
val layoutResult = remember { mutableStateOf<TextLayoutResult?>(null) }
Text(text = text,
modifier = Modifier.tapGestureFilter { pos ->
layoutResult.value?.let { layoutResult ->
val position = layoutResult.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(
width = style.fontSize,
height = style.fontSize,
placeholderVerticalAlign = PlaceholderVerticalAlign.Bottom
)
) {
CoilImage(it, alignment = Alignment.Center)
}
),
onTextLayout = { layoutResult.value = it }
)
}

Have a project you'd like to submit? Fill this form, will ya!

If you like this snippet, you might also like:

Maker OS is an all-in-one productivity system for developers

I built Maker OS to track, manage & organize my life. Now you can do it too!