RichText Editor in Jetpack Compose

RichText Editor in Jetpack Compose

Motivation

Late last month, I found myself pondering over how difficult would it be to build a cross platform note taking app that would provide me with an experience that was similar to what I get from the Notes app on my apple devices (small feature set, availability across multiple devices including android and not subscriptions or extraneous features that i don't use).

From that train of thought, I stumbled upon the idea of building a rich text editor that was capable of taking the user's typed text, and then rendering a formatted version of it to the screen. While I never got to building the cross platform note taking app I had dreamt off, I did do a lot of digging into get rich text editing working for the TextField from jetpack compose and this blog is a mental dump of everything I found.

Idea

From the start, the idea in my head was similar to how markdown works. I find the simplicity of typing some annotation before your actual text and the processor figuring the rest out by itself and rendering the correct text styles to screen. The challenge however, was to see if there was a way to make it realtime, such that as the user types text with annotations, we are able to render the correct text style to screen.

First Attempt

I started this attempt by focussing on the processing of the text as it was being entered and then rendering a processed version to the screen that would have all the styles attached to it.

To that end, I came up with a view model class that would hold a state object of TextFieldValue type and also have update mechanism where the TextField would provide the update from the user to the view model and it would process the incoming text and then update the TextField with the processed string with styles. The following is what it looks like in practice:


class RickTextFieldViewModel {
    private val edits = mutableListOf<Edit>()
    private var _textFieldValue = MutableStateFlow(
        TextFieldValue(
            annotatedString = createAnnotatedString(text = "")
        )
    )


    val textFieldValue: StateFlow<TextFieldValue> = _textFieldValue

    fun updateTextFieldValue(newValue: TextFieldValue) {
        _textFieldValue.value = newValue.copy(annotatedString = createAnnotatedString(text = newValue.text))
    }

    fun processEditType(style: Style) {
        val edit = Edit(
            range = IntRange(start = selection.start, endInclusive = selection.end),
            style = style
        )
        processEdits(edit)
        _textFieldValue.value =
            _textFieldValue.value.copy(annotatedString = createAnnotatedString(text = _textFieldValue.value.text))
    }

    private fun processEdits(edit: Edit) {
        // if edit is already contained then remove it as is
        if (edits.contains(edit)) {
            edits.remove(edit)
            return
        }

        if (edits.isEmpty()) {
            edits.add(edit)
            return
        }

        val processedList = mutableListOf<Edit>()
        processedList.addAll(edits.filter { it.style != edit.style })

        val sortedList = edits.filter { it.style == edit.style }.toMutableList()
        if (sortedList.isEmpty()) {
            edits.add(edit)
            return
        }

        var merged = false
        for (e in sortedList) {
            if (edit.range.last <= e.range.first || edit.range.first >= e.range.last) {
                processedList.add(e)
            } else {
                merged = true
                processedList.add(
                    Edit(
                        style = edit.style,
                        range = IntRange(
                            start = e.range.first.coerceAtMost(edit.range.first),
                            endInclusive = e.range.last.coerceAtLeast(edit.range.last)
                        )
                    )
                )
            }
        }

        if (!merged) {
            processedList.add(edit)
        }

        edits.clear()
        edits.addAll(processedList)
    }

    private fun createAnnotatedString(text: String): AnnotatedString = when (edits.size) {
        0 -> processVisualAnnotationsOnString(text = text)
        else -> processEditsOnString(text = processVisualAnnotationsOnString(text = text))
    }

    private fun processEditsOnString(text: AnnotatedString): AnnotatedString {
        val spanStyles = text.spanStyles.toMutableList()
        for (edit in edits) {
            spanStyles.add(
                AnnotatedString.Range(
                    getStyle(style = edit.style),
                    start = edit.range.first,
                    end = edit.range.last
                )
            )
        }

        return AnnotatedString(text = text.text, spanStyles = spanStyles)
    }

    private fun processVisualAnnotationsOnString(text: String): AnnotatedString {
        val builder = AnnotatedString.Builder()
        val matches = HASHTAG_REGEX_PATTERN.findAll(text)
        var startIndex = 0
        for (match in matches) {
            // append everything before match
            builder.append(text.substring(startIndex = startIndex, endIndex = match.range.first))

            // append hashtag style
            builder.withStyle(style = getStyle(style = Style.HASHTAG)) {
                append(text.substring(range = match.range))
            }
            startIndex = match.range.last + 1
        }
        if (startIndex <= text.length - 1) {
            builder.append(text.substring(startIndex = startIndex, endIndex = text.length))
        }
        return builder.toAnnotatedString()
    }

    private fun getStyle(style: Style): SpanStyle = when (style) {
        Style.HASHTAG -> RichTextFieldStyles.HashTags.toSpanStyle()
        Style.BOLD -> RichTextFieldStyles.Bold.toSpanStyle()
        Style.HEADING -> RichTextFieldStyles.Heading.toSpanStyle()
        Style.ITALICS -> RichTextFieldStyles.Italics.toSpanStyle()
        Style.TITLE -> RichTextFieldStyles.Title.toSpanStyle()
        Style.SUBHEADING -> RichTextFieldStyles.SubHeading.toSpanStyle()
        Style.BODY -> RichTextFieldStyles.Body.toSpanStyle()
    }
}

As can be seen, the view model maintains a local list of edits being made by the user as well as the current TextFieldValue state. Anytime, the user updates the text in the textfield, that update is passed back to the view model, that in turn runs it through a bunch of processing steps, that use the local list of edits, to come up with an annotated string with styles attached to different parts of the string. You can see it working in action below:

output.gif

This worked surprisingly well but it had some major shortfalls that I could see:

  • This wasn't realtime formatting. This way of doing formatting required the user to have to stop what they were typing and then select a piece of text and apply a style to it.

  • Changing already formatted text, or any text for that matter of fact, would cause the processing to go haywire. e.g if you had a bold piece of text and you would remove a character from that text, the system had to recompute all the styles after that text since no every character is one index less than what it was before. This meant that any change in text, would mean re-computing the entire local list of edits.

  • Lastly, what I found was that I was finding workarounds for things that were missing from the AnnotatedString class. For e.g when changing text in side of an annotated string, the class does not re-compute the styles automatically and rather you have to do it by hand. This meant a lot of tedious work that I couldn't get to work properly. I'd miss an edge case here or there always.

A different approach

Having not found the right approach, I took a step back and went back to the documentation on Jetpack compose's website. Here's where I noticed this section of the documentation which refers to how to use the visualTransformation param in the TextField to provide a class capable of visually transforming the string being rendered to the screen. It's how we can achieve password masking in textfield's and this approach sounded more promising than what I had come up with.

So I pivoted. From processing the text as it was typed and before it was rendered, I would instead focus on writing a visual transformer of sorts that hooks into the existing api and would compute the styles after the text had been typed. To that end, the new approach looks as follows

val HASHTAG_REGEX_PATTERN = Regex(pattern = "(#[A-Za-z0-9-_]+)(?:#[A-Za-z0-9-_]+)*")
val BOLD_REGEX_PATTERN = Regex(pattern = "(\\*{2})(\\s*\\b)([^\\*]*)(\\b\\s*)(\\*{2})")
val ITALICS_REGEX_PATTERN = Regex(pattern = "(\\~{2})(\\s*\\b)([^\\*]*)(\\b\\s*)(\\~{2})")
val HEADING_REGEX_PATTERN = Regex(pattern = "\\#{1,4}\\s([^\\#]*)\\s\\#{1,4}(?=\\n)")

class TextEditorVisualTransformer : VisualTransformation {

    override fun filter(text: AnnotatedString): TransformedText {
        var transformation = transformBold(text = text)
        transformation = transformItalics(text = transformation.annotatedString)
        transformation = transformHashtags(text = transformation.annotatedString)
        transformation = transformHeading(text = transformation.annotatedString)

        return convertToTransformedText(transformation)
    }

    private fun convertToTransformedText(transformation: Transformation): TransformedText {
        return TransformedText(text = transformation.annotatedString, offsetMapping = transformation.offsetMapping)
    }
}

fun transformHeading(text: AnnotatedString): Transformation {
    val matches = HEADING_REGEX_PATTERN.findAll(text.text)
    return if (matches.count() > 0) {
        val builder = AnnotatedString.Builder(text)
        for (match in matches) {
            val matchRange = match.range
            val headingLevel = getHeadingLevel(match.value)
            val sizeList = listOf(32.sp, 28.sp, 24.sp, 18.sp)
            builder.addStyle(
                style = SpanStyle(color = Color.Gray, baselineShift = BaselineShift.Superscript, fontSize = sizeList[headingLevel - 1] / 4),
                matchRange.first,
                matchRange.first + headingLevel
            )
            builder.addStyle(
                style = SpanStyle(fontWeight = FontWeight.Bold, fontSize = sizeList[headingLevel - 1]),
                matchRange.first + headingLevel,
                matchRange.last - headingLevel + 1
            )
            builder.addStyle(
                style = SpanStyle(color = Color.Gray, baselineShift = BaselineShift.Superscript, fontSize = sizeList[headingLevel - 1] / 4),
                matchRange.last - headingLevel + 1,
                matchRange.last + 1
            )
        }
        Transformation(annotatedString = builder.toAnnotatedString(), offsetMapping = OffsetMapping.Identity)
    } else {
        Transformation(annotatedString = text, offsetMapping = OffsetMapping.Identity)
    }
}

fun transformItalics(text: AnnotatedString): Transformation {
    val matches = ITALICS_REGEX_PATTERN.findAll(text.text)
    return if (matches.count() > 0) {
        val builder = AnnotatedString.Builder(text)
        for (match in matches) {
            val matchRange = match.range
            builder.addStyle(
                style = SpanStyle(color = Color.Gray, baselineShift = BaselineShift.Superscript, fontSize = 10.sp),
                matchRange.first,
                matchRange.first + 2
            )
            builder.addStyle(style = SpanStyle(fontStyle = FontStyle.Italic), matchRange.first + 2, matchRange.last - 1)
            builder.addStyle(
                style = SpanStyle(color = Color.Gray, baselineShift = BaselineShift.Superscript, fontSize = 10.sp),
                matchRange.last - 1,
                matchRange.last + 1
            )
        }
        Transformation(annotatedString = builder.toAnnotatedString(), offsetMapping = OffsetMapping.Identity)
    } else {
        Transformation(annotatedString = text, offsetMapping = OffsetMapping.Identity)
    }
}

fun transformBold(text: AnnotatedString): Transformation {
    val matches = BOLD_REGEX_PATTERN.findAll(text.text)
    return if (matches.count() > 0) {
        val builder = AnnotatedString.Builder(text)
        for (match in matches) {
            val matchRange = match.range
            builder.addStyle(
                style = SpanStyle(color = Color.Gray, baselineShift = BaselineShift.Superscript, fontSize = 10.sp),
                matchRange.first,
                matchRange.first + 2
            )
            builder.addStyle(style = SpanStyle(fontWeight = FontWeight.Bold), matchRange.first + 2, matchRange.last - 1)
            builder.addStyle(
                style = SpanStyle(color = Color.Gray, baselineShift = BaselineShift.Superscript, fontSize = 10.sp),
                matchRange.last - 1,
                matchRange.last + 1
            )
        }
        Transformation(annotatedString = builder.toAnnotatedString(), offsetMapping = OffsetMapping.Identity)
    } else {
        Transformation(annotatedString = text, offsetMapping = OffsetMapping.Identity)
    }
}

fun transformHashtags(text: AnnotatedString): Transformation {
    val builder = AnnotatedString.Builder(text)
    val matches = HASHTAG_REGEX_PATTERN.findAll(text.text)
    for (match in matches) {
        val matchRange = match.range
        builder.addStyle(style = SpanStyle(color = Color.Yellow), start = matchRange.first, end = matchRange.last + 1)
    }
    return Transformation(annotatedString = builder.toAnnotatedString(), offsetMapping = OffsetMapping.Identity)
}

private fun getHeadingLevel(text: String): Int {
    var i = 0
    while (i < text.length) {
        if (text[i] == '#') {
            i++
        } else {
            break
        }
    }
    return i
}

This approach is nothing but an implementation of the VisualTransformation interface. It uses a set of regular expressions to look for different patterns within the code and then associated different styles with those patterns. Once hooked into the TextField the visual transformer is consulted at every time the user changes the text inside the TextField hence rendering styles in realtime. You can see it working below:

For my use case this solve a couple of issues from before.

  • This is formatting the text in realtime. I can type in the bold annotation () and the second I am done typing the end of the annotation, the text becomes bold.

  • Lastly, since transformer works on regular expressions editing the text can be handled gracefully. For e.g if the user changes the text between the bold annotation, the next time the visual transformer is consulted it would still register the new text to be bold annotated since the regex pattern would match the new text. No need for complex re-calculation of styles every time something changes.

Not all is good with this approach as well. The result of the transformation has to be a TransformedText object. One of the params of this object is called OffsetMapping. In order words, offset maps are basically transformers themselves, that allow the textfield to know if the visual transformation process had added to subtracted characters from the original string (bad things happen if this doesn't work properly). Calculating these offset maps can get very complex depending on what your building. I get around this problem by not altering the existing text at all. I simply provide the annotation characters a style that makes them virtually invisible compared to the rest of the text. You can see this in action above.

Conclusion

It seems like that if you want real time formatting of TextField text then a highly optimized visual transformer can do the trick and would allow for a lot more flexibility than having to process the input at every step and manage the styles yourself.

If you would like to check out the code for this it's available here

I will in the future try to extract this out into it's own thing for people to use.