Senior Software Engineer
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.
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.TextFieldBuffer. You can intercept edits before committing them or format display text without manually computing offset mappings.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.TextFieldBuffer. You can apply styles with customizable ExpandPolicy options, and existing style ranges update automatically as users edit.Transcript
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) -> UnitIt 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.
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()
}
}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.
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.
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<*>): BooleanTrackedRange 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.
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.
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.
TextField(state = rememberTextFieldState())OutlinedTextField(state = rememberTextFieldState())BasicTextField(state = rememberTextFieldState())SecureTextField(state = rememberTextFieldState())@Composable
public fun BasicTextField(
state: TextFieldState,
)@Composable
public fun BasicTextField(
value: String,
onValueChange: (String) -> Unit
)
@Composable
public fun BasicTextField(
value: TextFieldValue,
onValueChange: (TextFieldValue) -> Unit// 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) }// Batch all operations in a single atomic edit:
state.edit {
insert(5, ",")
delete(12, 13)
replace(0, 5, "goodbye")
selection = TextRange(9, 16)
}fun TextFieldState.setTextAndPlaceCursorAtEnd(text: String) {
edit {
replace(0, length, text)
placeCursorAtEnd()
}
}fun TextFieldState.clearText() {
edit {
delete(0, length)
placeCursorAtEnd()
}
}onValueChange { newString ->
// full diffing new String vs old String
}@Stable
public fun interface InputTransformation {
public fun TextFieldBuffer.transformInput()
}// Intercept and filter input during typing
inputTransformation = InputTransformation {
val text = asCharSequence()
if (!text.all { it.isDigit() }) {
// Reject non-digit characters
revertAllChanges()
}
}@Stable
public fun interface OutputTransformation {
public fun TextFieldBuffer.transformOutput()
}// Raw data untouched; formatting is purely visual
outputTransformation = OutputTransformation {
if (length > 6) {
insert(6, "-")
}
if (length > 3) {
insert(3, "-")
}
}@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()) }) { }
)
}@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)
}
}@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,
)
}@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) { ... }
}@Stable
class TagEditorState(val textFieldState: TextFieldState = TextFieldState()) {}
@Composable
fun PostEditor(state: TagEditorState = rememberTagEditorState()) {
BasicTextField(
state = state.textFieldState,
inputTransformation = state.inputTransformation,
outputTransformation = state.outputTransformation,
)
}data class FormUiState(val username: TextFieldState)class FormViewModel : ViewModel() {
val username = TextFieldState()
val uiState: StateFlow<FormUiState> = ...
}class FormViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
val username: TextFieldState = savedStateHandle.saveable(
key = "username",
saver = TextFieldState.Saver,
init = { TextFieldState() }
)
val uiState: StateFlow<FormUiState> = ...
}@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)
}@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)
}@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)
}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)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>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)
}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")val state = TextFieldState("The fox")
state.edit {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = 4,
end = 7,
policy = ExpandPolicy.AtStart
)
insert(4, "quick ")val state = TextFieldState("The fox")
state.edit {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = 4,
end = 7,
policy = ExpandPolicy.AtEnd
)
insert(length, " jumps")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")
}// 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>>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<*>): Booleanfun 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())
}
}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)
}
}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
)
}
}
}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)
}
}
}