Apps foundation

Building reliable text editing experiences in Compose

Speakers

  • Anastasia Soboleva

    Software engineer

  • Haoyu Zhang

    Senior Software Engineer

About

About the talk

The journey to reliable text editing in Compose culminated in the complete rewrite of BasicTextField. This shift to a buffer-driven architecture eliminated IME race conditions once and for all. This session unpacks why legacy input syncing had to go and how TextFieldState keeps keyboard updates in sync. From there, take a deep dive into formatted editing to explore the mechanics of TrackedRange, the performance costs of edit blocks, and the real-world catches behind style boundaries, before taking an early peek at Markdown rendering.

Summary

  • The TextFieldState API overload replaces the callback-based overloads in Compose. It updates synchronously with user typing, eliminating timing issues with software keyboards and unnecessary recompositions.
  • Input and output transformations let you manipulate text using TextFieldBuffer. You can intercept edits before committing them or format display text without manually computing offset mappings.
  • Hoisting TextFieldState depends on your architecture needs. You can keep it locally in a composable or store it directly in a ViewModel for business logic access.
  • Compose 1.12 adds formatted text editing APIs to TextFieldBuffer. You can apply styles with customizable ExpandPolicy options, and existing style ranges update automatically as users edit.
  • A new markup text library for Jetpack Compose is currently in development. It supports formats like Markdown with incremental parsing and rendering for live editing and streaming.

Resources

Gemini Notebook

Topics

  • jetpack-compose
  • textfieldstate
  • text-editing
  • basictextfield
  • formatted-text
  • input-transformation

Transcript

Introduction to text editing in Compose

  • Hi, everyone!

  • Thank you for joining our talk.

  • My name is Anastasia, and with me is my colleague Haoyu.

  • And we are going to show you today how to build reliable text-editing experience.

  • Whether you are building a simple login screen, a search as you type bar, or a complex messaging app, text fields are at the heart of how users interact with your Android apps.

  • So, most of you probably already use material text fields, or outlined text fields.

    TextField(state = rememberTextFieldState())
    OutlinedTextField(state = rememberTextFieldState())
  • There is also a BasicTextField for if you are building an on your own design system, or you need a simple editing box.

    BasicTextField(state = rememberTextFieldState())
  • There is also SecureTextField which is specifically designed to enter passwords.

    SecureTextField(state = rememberTextFieldState())
  • And BasicTextField is an unstyled component and is actually powers all of other text fields in Compose.

  • So, you might have noticed over the past few years that we have two API shapes in parallel, one taking value and value change call back, and the other one taking TextFieldState object.

    @Composable
    public fun BasicTextField(
        state: TextFieldState,
    )
  • So, you might wonder which one we recommend.

  • We strongly encourage using the TextFieldState overload.

  • And we will discuss in this talk why it's the its benefits in terms of API, features, and performance.

  • It has been stable since Compose 1.7.

  • As for the call back overloads, we are soft-deprecating them in Compose 1.13 across BasicTextField and material wrappers.

    @Composable
    public fun BasicTextField(
        value: String,
        onValueChange: (String) -> Unit
    )
    
    @Composable
    public fun BasicTextField(
        value: TextFieldValue,
        onValueChange: (TextFieldValue) -> Unit
  • It means that we are not going to add any new features or fixing bugs unless you know, critical security issues or accessibility.

  • The stateless value on value change overload was really really worked well for synchronous inputs.

  • But as soon as you paired it with asynchronous streams, for instance, StateFlow, it created timing issues with the IME or as technical term, for the software keyboard.

  • So, that caused dropped characters, duplicated characters, cursor jumps, as you can see on this recording.

Introducing TextFieldState

  • To fix this fundamentally, we introduced TextFieldState.

  • It updates synchronously when the user types without waiting for those async round trips, which is exactly what the IME needed.

  • TextFieldState, in addition to the read-only text, also has other properties that kind of create a active editing session.

  • That is selection, undo redo state, and also span and span styles and paragraph styles for formatted text editing, more on that later in this talk.

  • The internal properties of the TextFieldState are backed by the Compose Snapshot System.

  • So, it means when you read those text or selection from that TextFieldState object somewhere in Compose, the specific scope where you read it from is subscribed to the updates.

  • It allows BasicTextField to only read state in the layout or draw phases, and together with some it eliminates the unnecessary recomposition when the user types.

  • And together with some other changes, it delivered a significant performance improvement for the TextFieldState overload.

  • Now, if the TextFieldState's text is a read-only property, how do you actually make programmatic changes to your input field?

  • And this is where the TextFieldBuffer comes in handy.

  • It's a mutable object that gives you APIs to make changes.

  • So, when you want to make the programmatic change, you call the edit function of the TextFieldState.

  • And inside that lambda, you get a TextFieldBuffer.

  • And TextFieldBuffer exposes several functions that allow you to insert, replace characters, delete them as required.

  • One quick tip with the edit is that avoid calling them multiple times in a row.

    // Often generated by AI agents:
    state.edit { insert(5, ",") }
    state.edit { delete(12, 13) }
    state.edit { replace(0, 5, "goodbye") }
    state.edit { selection = TextRange(9, 16) }
  • Instead, batch all of your operations into a single edit call.

    // Batch all operations in a single atomic edit:
    state.edit {
    insert(5, ",")
    delete(12, 13)
    replace(0, 5, "goodbye")
    selection = TextRange(9, 16)
    }
  • In that way, Compose will only create a single instance of the TextFieldBuffer, mutates it in place, and then synchronize it with the IME and Snapshot System once.

  • Another tip is that for most common batch operations that you will need, we actually have extension extension functions on the TextFieldState.

    fun TextFieldState.setTextAndPlaceCursorAtEnd(text: String) {
    edit {
    replace(0, length, text)
    placeCursorAtEnd()
    }
    }
  • So, you don't need to think about batching those operations.

  • You just call one of those.

  • An example would be clearText and setTextAndPlaceCursorAtEnd.

    fun TextFieldState.clearText() {
    edit {
    delete(0, length)
    placeCursorAtEnd()
    }
    }

Input filtering and formatting

  • So, now what about filtering the user input?

  • Like, enforcing a character limit or wanting to accept digits only.

  • In the old API, filtering in the onValueChange ran after the text was already committed.

    onValueChange { newString ->
      // full diffing new String vs old String
    }
  • And so, rejecting or altering that input kind of fought with the keyboard.

  • Also, the onValueChange callback only gave you the final string.

  • So to understand what changed, you had to do the full-string diffing.

  • Instead, in the new overload, we have input transformation.

    @Stable
    public fun interface InputTransformation {
        public fun TextFieldBuffer.transformInput()
    }
  • So it intercepts user input before that input reaches the text field state, and it give you gives you that same TextFieldBuffer APIs.

  • So this is how you implement the digits-only filtering with the input transformation, and you basically just take the change it take the changes.

    // Intercept and filter input during typing
    inputTransformation = InputTransformation {
        val text = asCharSequence()
        if (!text.all { it.isDigit() }) {
            // Reject non-digit characters
            revertAllChanges()
        }
    }
  • You check if there is non-digits characters, and then if there are, you reject the input by calling revertAllChanges on that TextFieldBuffer.

  • And the key capability of this input transformation is ChangeList.

  • So instead of diffing the full strings, it allows you to inspect the exact edit ranges, and then mutate them in place or reject the input altogether if needed.

  • And Compose will keep the keyboard and cursor in sync automatically for you.

  • Finally, what if you want to do visual formatting of your input?

  • For example, you want to add parentheses or dashes into a credit card number or phone number.

  • Oh, yeah.

  • And you don't want to take to store those in your database, right?

  • So previously, you used visual transformation API.

  • So in that case, you had to implement a bi-directional mapping from the raw input to the visual output, and then back.

  • And it was really difficult to make it right, which often led to runtime exceptions.

  • Now we have an output transformation, and then, again, under the hood, it is powered by the same TextFieldBuffer, so you get the same API pretty much.

    @Stable
    public fun interface OutputTransformation {
        public fun TextFieldBuffer.transformOutput()
    }
  • So in this phone number example, you simply, to insert those dash- dashes on a specific index, you simply call insert function of the TextFieldBuffer, and the system will calculate the bidirectional offset mapping for you.

    // Raw data untouched; formatting is purely visual
    outputTransformation = OutputTransformation {
        if (length > 6) {
            insert(6, "-")
        }
        if (length > 3) {
            insert(3, "-")
        }
    }
  • So this eliminates manual math and decreases the amount of crashes significantly.

Hoisting and migrating TextFieldState

  • Now to the most important part, how do you actually migrate, and where do you hoist that TextFieldState?

  • In practice, almost every use case falls into one of those categories that I'm going to talk.

  • And when it is not, it Well, usually it means that you have some unconventional architecture, but these examples are still will still g- guide you through your migration.

  • Okay, so the simplest case is to keep the TextFieldState right inside your composable where that text field lives.

    @Composable
    fun PromoCodeInput(onApply: (String) -> Unit) {
        // State hoisted locally inside the composable
        val state = rememberTextFieldState()
    
        OutlinedTextField(
            state = state,
            inputTransformation = InputTransformation.maxLength(6).allCaps(Locale.current),
            trailingIcon = { Button(onClick = { onApply(state.text.toString()) }) { }
        )
    }
  • This is when the your text field is a self-contained input field, so, for instance, a promo code input field, and you only need to get access to that text when the user, for instance, submits presses the submit button, right?

  • And so your view model doesn't need to intercept the user input on every change.

  • So you just call rememberTextFieldState locally, and rememberTextFieldState also helps you with the state restoration after the process death.

  • Then you use input transformation for filtering, and you get the text from that state, TextFieldState object, and when the user submits, and you pass it through the lambda.

  • Okay, what if sibling UI elements like a character counter or a submit button also need to read the text or change it, make changes to it?

  • This brings us to a case where you hoist the TextFieldState object up to a common composable function, a common ancestor composable function.

    @Composable
    fun PostCommentBar(onSubmit: (String) -> Unit) {
        // to share between UI components
        val commentState = rememberTextFieldState()
    
        Column {
            CommentTextField(commentState)
            Row {
                Text("${commentState.text.length} / 280")
                SubmitButton(commentState)
            }
        }
  • And because TextFieldState is backed by the snapshot system, passing it through the ancestor doesn't trigger recomposition of the parents.

  • Only those places where you actually read the state's text will update.

  • Okay.

  • So for complex UI use cases where you need your TextFieldState, input and output transformation to coordinate state internally, instead of hoisting them separately and trying to wire them together in the composable function, use a state holder object.

    @Composable
    fun PostEditor() {
    
        // Hoisting and wiring related pieces separately in the composable
        val textFieldState = rememberTextFieldState()
        val tagRanges = remember { mutableStateListOf<TextRange>() }
        val inputTransform = remember { TagInputTransformation(tagRanges) }
        val outputTransform = remember { TagOutputTransformation(tagRanges) }
    
        BasicTextField(
            state = textFieldState,
            inputTransformation = inputTransform,
            outputTransformation = outputTransform,
        )
    }
  • So imagine you want to handle an in-line hashtag.

  • TextFieldState holds a raw text and cursor position.

  • Input transformation is responsible for detecting a hashtag, and then also, you could do atomic tag deletion through the input transformation.

  • Output transformation would add additional visual styling and would all could also help you delete the hashtag.

  • And they are all together packed into a single state holder object, how do I call it, TagEditorState.

    @Stable
    class TagEditorState(val textFieldState: TextFieldState = TextFieldState()) {
    
        private val tagRanges = mutableStateListOf<TextRange>()
    
        // Transformations coordinate state internally
        val inputTransformation = TagInputTransformation(tagRanges)
        val outputTransformation = TagOutputTransformation(tagRanges)
    
        fun addTag(tag: String) { ... }
    
    }
  • And then, they can all reuse the intermediate calculations like tagRanges in this case.

  • And then, you simply pass this state holder object to your composable.

    @Stable
    class TagEditorState(val textFieldState: TextFieldState = TextFieldState()) {}
    
    @Composable
    fun PostEditor(state: TagEditorState = rememberTagEditorState()) {
        BasicTextField(
            state = state.textFieldState,
            inputTransformation = state.inputTransformation,
            outputTransformation = state.outputTransformation,
        )
    }
  • Okay, another use case is when your business logic actually needs access to that text for search as you type, or some complex validation, hoist that text field to into your ViewModel.

  • It's important that you don't put mutable text field object into immutable data class UI state because it gets copied with a copy method and then emitted into StateFlow on every change.

    data class FormUiState(val username: TextFieldState)
  • TextFieldState is a long-lived, mutable object backed by the Snapshot System, and its reference object never changes.

  • So, your StateFlow won't even know that user is typing at the moment because the reference stays the same.

  • And even worse, if you create a new instance of the UI state and you are allocating a fresh TextFieldState, your input field will lose all of its state.

  • So, that will be like selection, or cursor position, or undo/redo history.

  • So instead, make make TextFieldState a property directly on your ViewModel.

    class FormViewModel : ViewModel() {
       val username = TextFieldState()
       val uiState: StateFlow<FormUiState> = ...
    }
  • And don't worry about the no UI in the ViewModel rule here because, in that case, TextFieldState doesn't reference Android View or Context object or Compose lifecycle.

  • It's just a pure Snapshot-Backed state holder object.

  • So, in that case, it's kind of conceptually no different from StateFlow.

  • And one thing to remember that ViewModel doesn't have rememberSaveable, so you need to wire TextFieldState.Saver for text field to survive process death and restore its state.

    class FormViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
        val username: TextFieldState = savedStateHandle.saveable(
            key = "username",
            saver = TextFieldState.Saver,
            init = { TextFieldState() }
        )
       val uiState: StateFlow<FormUiState> = ...
    }
  • Now, what if your team has a strict module rules where ViewModels only lives in pure Kotlin modules, and you can't bring that compose-foundation dependency?

  • Or for instance, you're migrating a complex legacy screen, and you can't update or and refactor UI state today?

  • In that case, use the conforming approach.

  • So, you keep the TextFieldState in the composable as a single source of truth for that text field.

  • But then, you use LaunchedEffect with a snapshotFlow to push that text changes back into your existing ViewModel.

    @Composable
    fun LoginScreen(viewModel: LoginViewModel) {
        val uiState by viewModel.uiState.collectAsStateWithLifecycle()
        // 1. Hoist TextFieldState in UI layer
        val usernameState = rememberTextFieldState(initialText = uiState.initialUsername)
    
        // 2. Bridge UI -> ViewModel, one-way only (eliminates cursor jumps/dropped chars)
        LaunchedEffect(usernameState, viewModel) {
            snapshotFlow { usernameState.text.toString() }
                .drop(1)
                .collect { viewModel.onUsernameChanged(it) }
        }
    
        BasicTextField(state = usernameState)
    }
  • And your ViewModel stays Compose-free, and because UI state properties do not feed back into the text field on every change, you're still avoiding those async round trip bugs.

  • Yeah, and watch out for a common trap here.

    @Composable
    fun LoginScreen(viewModel: LoginViewModel) {
        val uiState by viewModel.uiState.collectAsStateWithLifecycle()
        // 1. Hoist TextFieldState in UI layer
        val usernameState = rememberTextFieldState(initialText = uiState.initialUsername)
    
        // 2. Bridge UI -> ViewModel, one-way only (eliminates cursor jumps/dropped chars)
        LaunchedEffect(usernameState, viewModel) {
            snapshotFlow { usernameState.text.toString() }
                .drop(1) // 👈 CRITICAL: Avoids firing on initial composition or rotation
                .collect { viewModel.onUsernameChanged(it) }
        }
    
        BasicTextField(state = usernameState)
    }
  • In most cases, you want to omit the first event.

  • This will prevent unnecessarily duplicated search queries, or it will prevent also overwriting your ViewModel with the initial value.

  • Okay, so if your logic then needs to feed back to you know to feedback those changes to the state, so for instance, when the user presses submit button and you want to clear the text, you can't just set empty string to ViewModel's to ViewModel.

    @Composable
    fun LoginScreen(viewModel: LoginViewModel) {
        val uiState by viewModel.uiState.collectAsStateWithLifecycle()
        // 1. Hoist TextFieldState in UI layer
        // 2. Bridge UI -> ViewModel, one-way only (eliminates cursor jumps/dropped chars)
        ...
    
        // 3. Handle discrete ViewModel events (e.g. clear after login)
        LaunchedEffect(viewModel) {
            viewModel.clearUsernameEvent.collect {
                usernameState.clearText()
            }
        }
        BasicTextField(state = usernameState)
    }
  • Instead, you need to emit a clear text event, and then the compo- composable function that hold that TextFieldState object will call clearText on that object.

  • To assist you even further with the transition, we wrote this migration guide.

  • You can access it via this link.

  • We're also working on some additional API to make both migration, as well as like general TextFieldState changes observation easier, so stay tuned for that.

Formatted and rich text editing

  • And everything we've covered so far, TextFieldState, TextFieldBuffer, input and output transformation, weren't built just to just for plain text input.

  • These are this is the foundation that powers our newest set of APIs for formatted text editing.

  • So now over to Haoyu to tell you more about this.

  • All right.

  • Thank you.

  • Hello, everyone.

  • Next, I will talk about new APIs we've introduced for formatted text editing.

  • So, before we dive into the new APIs, let's take a step back and look how the styling works in static text.

  • So most of you already know AnnotatedString really well.

    val styledText = buildAnnotatedString {
        append("Jetpack Compose ")
        // 1. Blue & Bold on "Jetpack" [0, 7)
        addStyle(
            style = SpanStyle(
                color = Color.Blue,
                fontWeight = FontWeight.Bold
            ),
            start = 0,
            end = 7
        )
    }
    // Render static formatted text
    Text(text = styledText)
  • It's the standard way to styling a static text.

  • We use buildAnnotatedString and call add-- call addStyle to apply SpanStyle or ParagraphStyle on certain given ranges.

  • It works well for the static text.

  • So when look-- when we look at formatted text editing, our initial thought was, can we just make AnnotatedString editable?

  • But then we quickly realized that's not very simple.

  • AnnotatedString is an immutable data structure.

  • But in the editing world, user is constantly typing.

  • And with every key-- every keystroke, we have to allocate a new AnnotatedString object.

  • And on each AnnotatedString, there are a lot of AnnotatedString.Range.

  • So those objects has to be recreated as well.

  • It doesn't scale very well and cause significant allocation overhead.

  • So, check this example.

  • When we insert the word in the middle, every style after it has shifted its range and get reallocated.

  • This clearly doesn't work when you have a lot of styles.

  • So instead, we've introduced dedicated styling APIs inside the TextFieldBuffer that Anastasia introduced before.

  • They're very similar to the styling APIs in AnnotatedString.

  • Let's dive into them how they work.

  • So just like AnnotatedString.Builder, TextFieldBuffer now has addStyle to apply SpanStyle or ParagraphStyle to a range.

    fun addStyle(spanStyle: SpanStyle, start: Int, end: Int)
    
    fun addStyle(paragraphStyle: ParagraphStyle, start: Int, end: Int)
    
    fun addStyle(spanStyle: SpanStyle, start: Int, end: Int, expandPolicy: ExpandPolicy): TrackedRange<SpanStyle>
    
    fun addStyle(paragraphStyle: ParagraphStyle, start: Int, end: Int, expandPolicy: ExpandPolicy): TrackedRange<ParagraphStyle>
  • You can also pass an ExpandPolicy parameter, which controls how the style behaves when text is typed right at the style boundaries.

  • We'll look at that in a moment.

  • So first, let's look at how the style tracking works.

    val state = TextFieldState("The quick fox jumps")
    
    state.edit {
        addStyle(
            SpanStyle(fontWeight = FontWeight.Bold),
            start = 4,
            end = 13
        )
        insert(0, "Look! ")
        insert(16, "brown ")
        delete(16, 22)
        delete(10, 19)
    }
  • Once you add a style to the TextFieldBuffer, its range updates automatically as the text is edited.

  • In this example, we start by making some text bold.

  • When you insert text before it, the style range shifts forward.

  • And when you insert text inside it, the text range expands.

  • And when we delete text inside the range-- the style range shrinks accordingly.

  • And if all the text inside the style range is deleted, the style collapses and is automatically removed from the buffer.

  • So now what happens when the text is inserted right at the boundaries of a style range?

  • Is the newly inserted text covered by the style or not?

  • So, to let you control this, we've introduced ExpandPolicy APIs.

  • Let's look-- let's walk through them.

  • So the first is ExpandPolicy.InsideOnly.

    val state = TextFieldState("The fox")
    
    state.edit {
        addStyle(
            SpanStyle(fontWeight = FontWeight.Bold),
            start = 4,
            end = 7,
            policy = ExpandPolicy.InsideOnly
        )
        insert(4, "quick ")
        insert(length, " jumps")
  • In this mode, inserting text at the boundary will not expand the style ranges.

  • Here we first apply a bold range.

  • If we insert text at the start of the bold range, the style range shifts, but doesn't expand.

  • Similarly, if we insert text at the end of the bold range, the style range doesn't expand either.

  • So here's a demo showing how it works in an editor.

  • So next is ExpandPolicy.AtStart.

    val state = TextFieldState("The fox")
    
    state.edit {
        addStyle(
            SpanStyle(fontWeight = FontWeight.Bold),
            start = 4,
            end = 7,
            policy = ExpandPolicy.AtStart
        )
        insert(4, "quick ")
  • As the name suggest, inserting text at the start of the range expand the style range.

  • So when we insert text right before the style range expands to cover the newly inserted text.

  • So here's another demo showing how it works.

  • Third is ExpandPolicy.AtEnd.

    val state = TextFieldState("The fox")
    
    state.edit {
        addStyle(
            SpanStyle(fontWeight = FontWeight.Bold),
            start = 4,
            end = 7,
            policy = ExpandPolicy.AtEnd
        )
        insert(length, " jumps")
  • So we're inserting text at the end of the range expand the style range.

  • So this is also the most common, setting for the editors.

  • So here's a demo showing how it works.

  • So finally, it's ExpandPolicy.AtBoth, which expand the style when the text is inserted at the start of the range and at the end of the range.

    val state = TextFieldState("The fox")
    
    state.edit {
        addStyle(
            SpanStyle(fontWeight = FontWeight.Bold),
            start = 4,
            end = 7,
            policy = ExpandPolicy.AtBoth
        )
        insert(4, "quick ")
     insert(length, " jumps")
    }
  • So another demo for this.

  • We covered all the styling-applying APIs so far.

  • In addition to that, we also introduced new query APIs like getSpanStyles, getParagraphStyles to fetch the styles currently applied in a given range.

    // Outside edits. ReadOnly API in TextFieldState.
    val textFieldState.styles: TextFieldTextStyles
    fun TextFieldTextStyles.getSpanStyle(range: TextRange): List<AnnotatedString.Range<SpanStyle>>
    
    // Inside edits, returned TrackedRange
    fun TextFieldBuffer.getSpanStyles(range: TextRange): List<TrackedRange<SpanStyle>>
  • Here are two versions of the query APIs.

  • On the TextFieldState, the style query APIs are read-only.

  • They return a list of immutable AnnotatedString.Range objects.

  • And but in the TextFieldBuffer, getSpanStyles returned a list of newly introduced TrackedRange objects, which I will introduce later.

  • So all style queries are order aware.

  • They return the style ranges exactly the order when the style is applied.

  • So this allows you to apply, just like AnnotatedString, to apply another style on top of an existing style to overwrite the earlier ones.

  • So next is TrackedRange API.

    var TrackedRange<*>.textRange: TextRange
    
    var TrackedRange<*>.expandPolicy: ExpandPolicy
    
    var TrackedRange<SpanStyle>.spanStyle: SpanStyle
    
    var TrackedRange<ParagraphStyle>.paragraphStyle: ParagraphStyle
    
    val TrackedRange<*>.isValid: Boolean
    
    fun removeStyle(trackedRange: TrackedRange<*>): Boolean
  • TrackedRange objects are returned by addStyle or getSpanStyles APIs in TextFieldBuffer.

  • Think of it as a live handle of the style range in the buffer.

  • You can use it to update the text range, ExpandPolicy or the style itself in the place.

  • It also has an isValid property to check if the style range is still in the TextFieldBuffer.

  • This is because style can be removed if the-- if it's collapsed during the editing, or it gets removed deliberately by the removeStyle.

  • So next let's see how those APIs come together in a real-world use cases.

Building a WYSIWYG editor

  • The first use case is build What You See Is What You Get, WYSIWYG Editor.

  • In this example, we implemented the bold button feature.

  • Clicking the bold button toggles the bold style on the selected text.

  • So next, let's look at the code.

  • So when the user taps the bold button, we first call getSpanStyles on a selection to find all the overlapping styles.

    fun TextFieldBuffer.toggleBoldSelection() {
        if (selection.collapsed) return
        val boldSpans = getSpanStyles(selection)
    
        if (!isFullyBold(boldSpans, selection)) {
            addStyle(
                SpanStyle(fontWeight = FontWeight.Bold),
                selection,
                ExpandPolicy.AtEnd
            )
        } else {
            unboldSelection(boldSpans.first())
        }
    }
  • And then we check if the entire selection is already bold or not.

  • Here, the selected word is not bold.

  • Since it's not bold, we addStyle to the selected range.

  • We pass ExpandPolicy.AtEnd, so that user can continue typing the bold right after this word.

  • Now, let's look what happens if the selected range is already bold.

    fun TextFieldBuffer.unboldSelection(
        boldSpan: TrackedRange<SpanStyle>
    ) {
        val hasBoldBefore = boldSpan.start < selection.min
        val hasBoldAfter = boldSpan.end > selection.max
    
        when {
            hasBoldBefore ->
                boldSpan.range = TextRange(range.start, selection.min)
            hasBoldAfter ->
                boldSpan.range = TextRange(selection.max, range.end)
            hasBoldBefore && hasBoldAfter -> {
                val oldEnd = boldSpan.end
                boldSpan.range = TextRange(boldSpan.start, selection.min)
                addStyle(
                    boldSpan.spanStyle,
                    start = selection.max,
                    end = oldEnd
                )
            }
            else -> removeStyle(range)
        }
    }
  • In that case, we need to clear the bold style from the selected range.

  • Here, the user-selected the word is inside the longer bold phrase.

  • Because the bold style extends before the selection, we need to update the style range, leaving the selected word unbolded.

  • To do this, we can use a TrackedRange API.

  • And for this specific case, instead of deleting this style and recreating it, we can just adjust the bold span range in-place to end before the selection at index 10.

  • Of course, this is a highly simplified implementation.

  • For a full demo, you can check our Compose samples repository.

  • And next, let's look at a pattern-based styling.

  • In this demo, when user types Markdown asterisks around a word, the editor automatically converts it into bold text as they type.

  • Now, let's see how it's implemented using InputTransformation.

    object MarkdownBoldTransformation : InputTransformation {
        val regex = Regex("""**(.*?)**""")
    
        override fun TextFieldBuffer.transformInput() {
            val matches = regex.findAll(asCharSequence()).toList()
    
            for (match in matches) {
                val range = match.range
                val content = match.groupValues[1]
    
                replace(range.first, range.last + 1, content)
                addStyle(
                    SpanStyle(fontWeight = FontWeight.Bold),
                    start = range.first,
                    end = range.first + content.length,
                    policy = ExpandPolicy.atEnd
                )
            }
        }
    }
  • In our transforming input filter, we find all the matching patterns over the button.

  • And for each match, we extract the content inside the double asterisks.

  • In this example, it's the word "fox".

  • And next, we remove the asterisks and replace the matching pattern with its content.

  • And finally, we call addStyle to the bold to apply the bold style to the content text.

  • Just remember, InputTransformation will submit to the final text field state.

  • This means the bold style is applied to the state persistently.

  • And finally, let's look at the search highlighting example.

  • So in this example, when user types a search query, matching words in the text fields is are highlighted, without modifying the text field state.

  • Now, let's look how it's implemented in the OutputTransformation.

  • When highlighting a search word, you should never mutate the text state.

  • And OutputTransformation is a perfect place to implement this use case.

  • So let's check the actual implementation.

  • We first search the highlighting words and find all the matches.

    class SearchHighlightTransformation(
        val query: MutableState<String>
    ) : OutputTransformation {
        override fun TextFieldBuffer.transformOutput() {
            val search = query.value
            if (search.isEmpty()) return
            val text = asCharSequence().toString()
            var index = text.indexOf(search)
    
            while (index != -1) {
                addStyle(
                    SpanStyle(
                        background = Color.Yellow,
                        color = Color.Black
                    ),
                    start = index,
                    end = index + search.length
                )
                index = text.indexOf(search, index + search.length)
            }
        }
    }
  • And note that the query is a mutable state.

  • This is because the OutputTransformation is snapshot-aware.

  • When this mutable states gets updated, the OutputTransformation is reapplied automatically.

  • And then when we loops through every matches and call addStyle to draw the yellow highlights over the match range.

  • Again, OutputTransformation only change the visual text, and the original text states are untouched.

  • Okay, that's all the use cases we have.

  • Just to remind you, all the APIs showed before are available starting from Compose 1.12.

Upcoming markup text library

  • And to wrap up, I want to share a sneak peek of the new project we're currently working on.

  • It's a markup text library for Jetpack Compose, which support rich text formats like Markdown.

  • One of the most exciting capabilities we're supporting is incremental parsing and incremental rendering.

  • So it's very suitable for live editing or the Markdown streaming.

  • If here's a record of our working prototype in action, and you can see those fading areas are the updating parts in the UI.

  • If this is something you are interested, or you have some use case in your app, please feel free to reach out to us.

  • And thank you, everyone.

Code

Code snippets

Kotlin snippet at 00:29 in "Introduction to text editing in Compose"
TextField(state = rememberTextFieldState())
Kotlin snippet at 00:29 in "Introduction to text editing in Compose"
OutlinedTextField(state = rememberTextFieldState())
Kotlin snippet at 00:34 in "Introduction to text editing in Compose"
BasicTextField(state = rememberTextFieldState())
Kotlin snippet at 00:44 in "Introduction to text editing in Compose"
SecureTextField(state = rememberTextFieldState())
Kotlin snippet at 00:58 in "Introduction to text editing in Compose"
@Composable
public fun BasicTextField(
    state: TextFieldState,
)
Kotlin snippet at 01:28 in "Introduction to text editing in Compose"
@Composable
public fun BasicTextField(
    value: String,
    onValueChange: (String) -> Unit
)

@Composable
public fun BasicTextField(
    value: TextFieldValue,
    onValueChange: (TextFieldValue) -> Unit
Kotlin snippet at 04:01 in "Introducing TextFieldState"
// Often generated by AI agents:
state.edit { insert(5, ",") }
state.edit { delete(12, 13) }
state.edit { replace(0, 5, "goodbye") }
state.edit { selection = TextRange(9, 16) }
Kotlin snippet at 04:09 in "Introducing TextFieldState"
// Batch all operations in a single atomic edit:
state.edit {
insert(5, ",")
delete(12, 13)
replace(0, 5, "goodbye")
selection = TextRange(9, 16)
}
Kotlin snippet at 04:25 in "Introducing TextFieldState"
fun TextFieldState.setTextAndPlaceCursorAtEnd(text: String) {
edit {
replace(0, length, text)
placeCursorAtEnd()
}
}
Kotlin snippet at 04:38 in "Introducing TextFieldState"
fun TextFieldState.clearText() {
edit {
delete(0, length)
placeCursorAtEnd()
}
}
Kotlin snippet at 04:55 in "Input filtering and formatting"
onValueChange { newString ->
  // full diffing new String vs old String
}
Kotlin snippet at 05:17 in "Input filtering and formatting"
@Stable
public fun interface InputTransformation {
    public fun TextFieldBuffer.transformInput()
}
Kotlin snippet at 05:33 in "Input filtering and formatting"
// Intercept and filter input during typing
inputTransformation = InputTransformation {
    val text = asCharSequence()
    if (!text.all { it.isDigit() }) {
        // Reject non-digit characters
        revertAllChanges()
    }
}
Kotlin snippet at 06:48 in "Input filtering and formatting"
@Stable
public fun interface OutputTransformation {
    public fun TextFieldBuffer.transformOutput()
}
Kotlin snippet at 06:58 in "Input filtering and formatting"
// Raw data untouched; formatting is purely visual
outputTransformation = OutputTransformation {
    if (length > 6) {
        insert(6, "-")
    }
    if (length > 3) {
        insert(3, "-")
    }
}
Kotlin snippet at 07:51 in "Hoisting and migrating TextFieldState"
@Composable
fun PromoCodeInput(onApply: (String) -> Unit) {
    // State hoisted locally inside the composable
    val state = rememberTextFieldState()

    OutlinedTextField(
        state = state,
        inputTransformation = InputTransformation.maxLength(6).allCaps(Locale.current),
        trailingIcon = { Button(onClick = { onApply(state.text.toString()) }) { }
    )
}
Kotlin snippet at 08:58 in "Hoisting and migrating TextFieldState"
@Composable
fun PostCommentBar(onSubmit: (String) -> Unit) {
    // to share between UI components
    val commentState = rememberTextFieldState()

    Column {
        CommentTextField(commentState)
        Row {
            Text("${commentState.text.length} / 280")
            SubmitButton(commentState)
        }
    }
Kotlin snippet at 09:27 in "Hoisting and migrating TextFieldState"
@Composable
fun PostEditor() {

    // Hoisting and wiring related pieces separately in the composable
    val textFieldState = rememberTextFieldState()
    val tagRanges = remember { mutableStateListOf<TextRange>() }
    val inputTransform = remember { TagInputTransformation(tagRanges) }
    val outputTransform = remember { TagOutputTransformation(tagRanges) }

    BasicTextField(
        state = textFieldState,
        inputTransformation = inputTransform,
        outputTransformation = outputTransform,
    )
}
Kotlin snippet at 10:12 in "Hoisting and migrating TextFieldState"
@Stable
class TagEditorState(val textFieldState: TextFieldState = TextFieldState()) {

    private val tagRanges = mutableStateListOf<TextRange>()

    // Transformations coordinate state internally
    val inputTransformation = TagInputTransformation(tagRanges)
    val outputTransformation = TagOutputTransformation(tagRanges)

    fun addTag(tag: String) { ... }

}
Kotlin snippet at 10:30 in "Hoisting and migrating TextFieldState"
@Stable
class TagEditorState(val textFieldState: TextFieldState = TextFieldState()) {}

@Composable
fun PostEditor(state: TagEditorState = rememberTagEditorState()) {
    BasicTextField(
        state = state.textFieldState,
        inputTransformation = state.inputTransformation,
        outputTransformation = state.outputTransformation,
    )
}
Kotlin snippet at 10:51 in "Hoisting and migrating TextFieldState"
data class FormUiState(val username: TextFieldState)
Kotlin snippet at 11:38 in "Hoisting and migrating TextFieldState"
class FormViewModel : ViewModel() {
   val username = TextFieldState()
   val uiState: StateFlow<FormUiState> = ...
}
Kotlin snippet at 12:09 in "Hoisting and migrating TextFieldState"
class FormViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
    val username: TextFieldState = savedStateHandle.saveable(
        key = "username",
        saver = TextFieldState.Saver,
        init = { TextFieldState() }
    )
   val uiState: StateFlow<FormUiState> = ...
}
Kotlin snippet at 12:55 in "Hoisting and migrating TextFieldState"
@Composable
fun LoginScreen(viewModel: LoginViewModel) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    // 1. Hoist TextFieldState in UI layer
    val usernameState = rememberTextFieldState(initialText = uiState.initialUsername)

    // 2. Bridge UI -> ViewModel, one-way only (eliminates cursor jumps/dropped chars)
    LaunchedEffect(usernameState, viewModel) {
        snapshotFlow { usernameState.text.toString() }
            .drop(1)
            .collect { viewModel.onUsernameChanged(it) }
    }

    BasicTextField(state = usernameState)
}
Kotlin snippet at 13:21 in "Hoisting and migrating TextFieldState"
@Composable
fun LoginScreen(viewModel: LoginViewModel) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    // 1. Hoist TextFieldState in UI layer
    val usernameState = rememberTextFieldState(initialText = uiState.initialUsername)

    // 2. Bridge UI -> ViewModel, one-way only (eliminates cursor jumps/dropped chars)
    LaunchedEffect(usernameState, viewModel) {
        snapshotFlow { usernameState.text.toString() }
            .drop(1) // 👈 CRITICAL: Avoids firing on initial composition or rotation
            .collect { viewModel.onUsernameChanged(it) }
    }

    BasicTextField(state = usernameState)
}
Kotlin snippet at 13:39 in "Hoisting and migrating TextFieldState"
@Composable
fun LoginScreen(viewModel: LoginViewModel) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    // 1. Hoist TextFieldState in UI layer
    // 2. Bridge UI -> ViewModel, one-way only (eliminates cursor jumps/dropped chars)
    ...

    // 3. Handle discrete ViewModel events (e.g. clear after login)
    LaunchedEffect(viewModel) {
        viewModel.clearUsernameEvent.collect {
            usernameState.clearText()
        }
    }
    BasicTextField(state = usernameState)
}
Kotlin snippet at 15:09 in "Formatted and rich text editing"
val styledText = buildAnnotatedString {
    append("Jetpack Compose ")
    // 1. Blue & Bold on "Jetpack" [0, 7)
    addStyle(
        style = SpanStyle(
            color = Color.Blue,
            fontWeight = FontWeight.Bold
        ),
        start = 0,
        end = 7
    )
}
// Render static formatted text
Text(text = styledText)
Kotlin snippet at 16:36 in "Formatted and rich text editing"
fun addStyle(spanStyle: SpanStyle, start: Int, end: Int)

fun addStyle(paragraphStyle: ParagraphStyle, start: Int, end: Int)

fun addStyle(spanStyle: SpanStyle, start: Int, end: Int, expandPolicy: ExpandPolicy): TrackedRange<SpanStyle>

fun addStyle(paragraphStyle: ParagraphStyle, start: Int, end: Int, expandPolicy: ExpandPolicy): TrackedRange<ParagraphStyle>
Kotlin snippet at 16:57 in "Formatted and rich text editing"
val state = TextFieldState("The quick fox jumps")

state.edit {
    addStyle(
        SpanStyle(fontWeight = FontWeight.Bold),
        start = 4,
        end = 13
    )
    insert(0, "Look! ")
    insert(16, "brown ")
    delete(16, 22)
    delete(10, 19)
}
Kotlin snippet at 17:52 in "Formatted and rich text editing"
val state = TextFieldState("The fox")

state.edit {
    addStyle(
        SpanStyle(fontWeight = FontWeight.Bold),
        start = 4,
        end = 7,
        policy = ExpandPolicy.InsideOnly
    )
    insert(4, "quick ")
    insert(length, " jumps")
Kotlin snippet at 18:22 in "Formatted and rich text editing"
val state = TextFieldState("The fox")

state.edit {
    addStyle(
        SpanStyle(fontWeight = FontWeight.Bold),
        start = 4,
        end = 7,
        policy = ExpandPolicy.AtStart
    )
    insert(4, "quick ")
Kotlin snippet at 18:40 in "Formatted and rich text editing"
val state = TextFieldState("The fox")

state.edit {
    addStyle(
        SpanStyle(fontWeight = FontWeight.Bold),
        start = 4,
        end = 7,
        policy = ExpandPolicy.AtEnd
    )
    insert(length, " jumps")
Kotlin snippet at 18:56 in "Formatted and rich text editing"
val state = TextFieldState("The fox")

state.edit {
    addStyle(
        SpanStyle(fontWeight = FontWeight.Bold),
        start = 4,
        end = 7,
        policy = ExpandPolicy.AtBoth
    )
    insert(4, "quick ")
 insert(length, " jumps")
}
Kotlin snippet at 19:14 in "Formatted and rich text editing"
// Outside edits. ReadOnly API in TextFieldState.
val textFieldState.styles: TextFieldTextStyles
fun TextFieldTextStyles.getSpanStyle(range: TextRange): List<AnnotatedString.Range<SpanStyle>>

// Inside edits, returned TrackedRange
fun TextFieldBuffer.getSpanStyles(range: TextRange): List<TrackedRange<SpanStyle>>
Kotlin snippet at 20:03 in "Formatted and rich text editing"
var TrackedRange<*>.textRange: TextRange

var TrackedRange<*>.expandPolicy: ExpandPolicy

var TrackedRange<SpanStyle>.spanStyle: SpanStyle

var TrackedRange<ParagraphStyle>.paragraphStyle: ParagraphStyle

val TrackedRange<*>.isValid: Boolean

fun removeStyle(trackedRange: TrackedRange<*>): Boolean
Kotlin snippet at 21:01 in "Building a WYSIWYG editor"
fun TextFieldBuffer.toggleBoldSelection() {
    if (selection.collapsed) return
    val boldSpans = getSpanStyles(selection)

    if (!isFullyBold(boldSpans, selection)) {
        addStyle(
            SpanStyle(fontWeight = FontWeight.Bold),
            selection,
            ExpandPolicy.AtEnd
        )
    } else {
        unboldSelection(boldSpans.first())
    }
}
Kotlin snippet at 21:26 in "Building a WYSIWYG editor"
fun TextFieldBuffer.unboldSelection(
    boldSpan: TrackedRange<SpanStyle>
) {
    val hasBoldBefore = boldSpan.start < selection.min
    val hasBoldAfter = boldSpan.end > selection.max

    when {
        hasBoldBefore ->
            boldSpan.range = TextRange(range.start, selection.min)
        hasBoldAfter ->
            boldSpan.range = TextRange(selection.max, range.end)
        hasBoldBefore && hasBoldAfter -> {
            val oldEnd = boldSpan.end
            boldSpan.range = TextRange(boldSpan.start, selection.min)
            addStyle(
                boldSpan.spanStyle,
                start = selection.max,
                end = oldEnd
            )
        }
        else -> removeStyle(range)
    }
}
Kotlin snippet at 22:27 in "Building a WYSIWYG editor"
object MarkdownBoldTransformation : InputTransformation {
    val regex = Regex("""**(.*?)**""")

    override fun TextFieldBuffer.transformInput() {
        val matches = regex.findAll(asCharSequence()).toList()

        for (match in matches) {
            val range = match.range
            val content = match.groupValues[1]

            replace(range.first, range.last + 1, content)
            addStyle(
                SpanStyle(fontWeight = FontWeight.Bold),
                start = range.first,
                end = range.first + content.length,
                policy = ExpandPolicy.atEnd
            )
        }
    }
}
Kotlin snippet at 23:42 in "Building a WYSIWYG editor"
class SearchHighlightTransformation(
    val query: MutableState<String>
) : OutputTransformation {
    override fun TextFieldBuffer.transformOutput() {
        val search = query.value
        if (search.isEmpty()) return
        val text = asCharSequence().toString()
        var index = text.indexOf(search)

        while (index != -1) {
            addStyle(
                SpanStyle(
                    background = Color.Yellow,
                    color = Color.Black
                ),
                start = index,
                end = index + search.length
            )
            index = text.indexOf(search, index + search.length)
        }
    }
}