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