Simple Markdown-like Formatting for a Compose TextField

May 10, 2024

For a simple note-taking app, I wanted to add some simple, Markdown-like formatting that is shown live as the user types. For this purpose, I found TextField’s visualTransformation: It allows you to provide a VisualTransformation that takes a text: AnnotatedString, which is the input the user entered into the TextField and returns another AnnotatedString that is the thing that is eventually displayed to the user.

A simple example is the predefined PasswordVisualTransformation: It returns an AnnotatedString that just repeats a symbol () for the number of chars in the input text. On top, you can also provide an OffsetMapping. That is useful if the visual text has a different length than the input text (e.g. for a phone number or credit card formatter).

But back to the simple formatting example: Let’s define the TextField like this:

@Composable
fun Example() {
    var text by remember { mutableStateOf(TextFieldValue("")) }
    TextField(
        modifier = Modifier.fillMaxWidth().fillMaxHeight(),
        value = text,
        onValueChange = { newText ->
            text = newText
        },
        visualTransformation = remember { SimpleFormattingVisualTransformation() },
    )
}

My SimpleFormattingVisualTransformation works by finding text between *…* to make them bold, and between `…` to format some inline code in monospace font and gray background. It uses Regex to find relevant pieces in the input and then applies the corresponding SpanStyle to the range of each match:

class SimpleFormattingVisualTransformation : VisualTransformation {
    override fun filter(text: AnnotatedString): TransformedText {
        return TransformedText(
            text = buildAnnotatedString {
                append(text)

                styleRegex(text, EMPHASIS_REGEX, EMPHASIS_STYLE)
                styleRegex(text, CODE_REGEX, CODE_STYLE)
            },
            offsetMapping = OffsetMapping.Identity
        )
    }

    private fun AnnotatedString.Builder.styleRegex(text: AnnotatedString, regex: Regex, spanStyle: SpanStyle) {
        regex.findAll(text).forEach { match ->
            addStyle(spanStyle, match.range.first, match.range.endExclusive)
        }
    }

    companion object {
        private val EMPHASIS_REGEX: Regex = Regex("\\*.*\\*")
        private val EMPHASIS_STYLE : SpanStyle = SpanStyle(fontWeight = FontWeight.Bold)

        private val CODE_REGEX: Regex = Regex("`.*`")
        private val CODE_STYLE : SpanStyle = SpanStyle(fontFamily = FontFamily.Monospace, background = Color.Gray)
    }
}

This works quite nicely:

However, this simple approach has limitations. Like mixing multiple formatting rules:

Or is it not a bug but a feature? You decide!

You might also like