# Building reliable text editing experiences in Compose - Speakers: Anastasia Soboleva (Software engineer), Haoyu Zhang (Senior Software Engineer) - Track: Apps foundation - Video: https://www.youtube.com/watch?v=1DvjkRYGs2g - Page: https://developer.android.com/events/dev-summit/talks/building-reliable-text-editing-experiences-in-compose ## 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 - [Migration guide to TextFieldState](https://goo.gle/migrate_to_state_textfield) ## Topics jetpack-compose, textfieldstate, text-editing, basictextfield, formatted-text, input-transformation ## Transcript ### Introduction to text editing in Compose [00:05](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=5s) Hi, everyone! [00:06](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=6s) Thank you for joining our talk. [00:08](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=8s) My name is Anastasia, and with me is my colleague Haoyu. [00:11](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=11s) And we are going to show you today how to build reliable text-editing experience. [00:16](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=16s) 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. [00:29](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=29s) So, most of you probably already use material text fields, or outlined text fields. ```kotlin TextField(state = rememberTextFieldState()) ``` ```kotlin OutlinedTextField(state = rememberTextFieldState()) ``` [00:34](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=34s) There is also a `BasicTextField` for if you are building an on your own design system, or you need a simple editing box. ```kotlin BasicTextField(state = rememberTextFieldState()) ``` [00:44](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=44s) There is also `SecureTextField` which is specifically designed to enter passwords. ```kotlin SecureTextField(state = rememberTextFieldState()) ``` [00:50](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=50s) And `BasicTextField` is an unstyled component and is actually powers all of other text fields in Compose. [00:58](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=58s) 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. ```kotlin @Composable public fun BasicTextField( state: TextFieldState, ) ``` [01:08](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=68s) So, you might wonder which one we recommend. [01:12](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=72s) We strongly encourage using the `TextFieldState` overload. [01:16](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=76s) And we will discuss in this talk why it's the its benefits in terms of API, features, and performance. [01:24](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=84s) It has been stable since Compose 1.7. [01:28](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=88s) As for the call back overloads, we are soft-deprecating them in Compose 1.13 across `BasicTextField` and material wrappers. ```kotlin @Composable public fun BasicTextField( value: String, onValueChange: (String) -> Unit ) @Composable public fun BasicTextField( value: TextFieldValue, onValueChange: (TextFieldValue) -> Unit ``` [01:37](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=97s) It means that we are not going to add any new features or fixing bugs unless you know, critical security issues or accessibility. [01:48](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=108s) The stateless value on value change overload was really really worked well for synchronous inputs. [01:56](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=116s) 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. [02:07](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=127s) So, that caused dropped characters, duplicated characters, cursor jumps, as you can see on this recording. ### Introducing TextFieldState [02:15](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=135s) To fix this fundamentally, we introduced `TextFieldState`. [02:19](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=139s) It updates synchronously when the user types without waiting for those async round trips, which is exactly what the IME needed. [02:28](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=148s) `TextFieldState`, in addition to the read-only text, also has other properties that kind of create a active editing session. [02:39](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=159s) 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. [02:50](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=170s) The internal properties of the `TextFieldState` are backed by the Compose Snapshot System. [02:54](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=174s) 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. [03:06](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=186s) 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. [03:17](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=197s) And together with some other changes, it delivered a significant performance improvement for the `TextFieldState` overload. [03:25](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=205s) Now, if the TextFieldState's text is a read-only property, how do you actually make programmatic changes to your input field? [03:33](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=213s) And this is where the TextFieldBuffer comes in handy. [03:36](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=216s) It's a mutable object that gives you APIs to make changes. [03:42](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=222s) So, when you want to make the programmatic change, you call the edit function of the `TextFieldState`. [03:48](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=228s) And inside that lambda, you get a TextFieldBuffer. [03:51](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=231s) And TextFieldBuffer exposes several functions that allow you to insert, replace characters, delete them as required. [04:01](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=241s) One quick tip with the edit is that avoid calling them multiple times in a row. ```kotlin // 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) } ``` [04:09](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=249s) Instead, batch all of your operations into a single edit call. ```kotlin // Batch all operations in a single atomic edit: state.edit { insert(5, ",") delete(12, 13) replace(0, 5, "goodbye") selection = TextRange(9, 16) } ``` [04:13](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=253s) 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. [04:25](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=265s) Another tip is that for most common batch operations that you will need, we actually have extension extension functions on the `TextFieldState`. ```kotlin fun TextFieldState.setTextAndPlaceCursorAtEnd(text: String) { edit { replace(0, length, text) placeCursorAtEnd() } } ``` [04:34](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=274s) So, you don't need to think about batching those operations. [04:37](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=277s) You just call one of those. [04:38](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=278s) An example would be `clearText` and `setTextAndPlaceCursorAtEnd`. ```kotlin fun TextFieldState.clearText() { edit { delete(0, length) placeCursorAtEnd() } } ``` ### Input filtering and formatting [04:45](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=285s) So, now what about filtering the user input? [04:49](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=289s) Like, enforcing a character limit or wanting to accept digits only. [04:55](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=295s) In the old API, filtering in the `onValueChange` ran after the text was already committed. ```kotlin onValueChange { newString -> // full diffing new String vs old String } ``` [05:01](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=301s) And so, rejecting or altering that input kind of fought with the keyboard. [05:07](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=307s) Also, the `onValueChange` callback only gave you the final string. [05:12](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=312s) So to understand what changed, you had to do the full-string diffing. [05:17](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=317s) Instead, in the new overload, we have input transformation. ```kotlin @Stable public fun interface InputTransformation { public fun TextFieldBuffer.transformInput() } ``` [05:21](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=321s) So it intercepts user input before that input reaches the text field state, and it give you gives you that same TextFieldBuffer APIs. [05:33](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=333s) 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. ```kotlin // Intercept and filter input during typing inputTransformation = InputTransformation { val text = asCharSequence() if (!text.all { it.isDigit() }) { // Reject non-digit characters revertAllChanges() } } ``` [05:40](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=340s) You check if there is non-digits characters, and then if there are, you reject the input by calling `revertAllChanges` on that TextFieldBuffer. [05:50](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=350s) And the key capability of this input transformation is ChangeList. [05:54](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=354s) 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. [06:06](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=366s) And Compose will keep the keyboard and cursor in sync automatically for you. [06:14](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=374s) Finally, what if you want to do visual formatting of your input? [06:19](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=379s) For example, you want to add parentheses or dashes into a credit card number or phone number. [06:26](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=386s) Oh, yeah. [06:26](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=386s) And you don't want to take to store those in your database, right? [06:30](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=390s) So previously, you used visual transformation API. [06:34](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=394s) So in that case, you had to implement a bi-directional mapping from the raw input to the visual output, and then back. [06:41](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=401s) And it was really difficult to make it right, which often led to runtime exceptions. [06:48](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=408s) 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. ```kotlin @Stable public fun interface OutputTransformation { public fun TextFieldBuffer.transformOutput() } ``` [06:58](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=418s) 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. ```kotlin // Raw data untouched; formatting is purely visual outputTransformation = OutputTransformation { if (length > 6) { insert(6, "-") } if (length > 3) { insert(3, "-") } } ``` [07:15](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=435s) So this eliminates manual math and decreases the amount of crashes significantly. ### Hoisting and migrating TextFieldState [07:24](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=444s) Now to the most important part, how do you actually migrate, and where do you hoist that `TextFieldState`? [07:33](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=453s) In practice, almost every use case falls into one of those categories that I'm going to talk. [07:39](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=459s) 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. [07:51](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=471s) Okay, so the simplest case is to keep the `TextFieldState` right inside your composable where that text field lives. ```kotlin @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()) }) { } ) } ``` [08:00](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=480s) 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? [08:15](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=495s) And so your view model doesn't need to intercept the user input on every change. [08:21](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=501s) So you just call `rememberTextFieldState` locally, and `rememberTextFieldState` also helps you with the state restoration after the process death. [08:32](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=512s) 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. [08:47](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=527s) 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? [08:58](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=538s) This brings us to a case where you hoist the `TextFieldState` object up to a common composable function, a common ancestor composable function. ```kotlin @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) } } ``` [09:08](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=548s) And because `TextFieldState` is backed by the snapshot system, passing it through the ancestor doesn't trigger recomposition of the parents. [09:17](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=557s) Only those places where you actually read the state's text will update. [09:26](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=566s) Okay. [09:27](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=567s) 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. ```kotlin @Composable fun PostEditor() { // Hoisting and wiring related pieces separately in the composable val textFieldState = rememberTextFieldState() val tagRanges = remember { mutableStateListOf() } val inputTransform = remember { TagInputTransformation(tagRanges) } val outputTransform = remember { TagOutputTransformation(tagRanges) } BasicTextField( state = textFieldState, inputTransformation = inputTransform, outputTransformation = outputTransform, ) } ``` [09:45](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=585s) So imagine you want to handle an in-line hashtag. [09:49](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=589s) `TextFieldState` holds a raw text and cursor position. [09:53](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=593s) Input transformation is responsible for detecting a hashtag, and then also, you could do atomic tag deletion through the input transformation. [10:05](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=605s) Output transformation would add additional visual styling and would all could also help you delete the hashtag. [10:12](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=612s) And they are all together packed into a single state holder object, how do I call it, `TagEditorState`. ```kotlin @Stable class TagEditorState(val textFieldState: TextFieldState = TextFieldState()) { private val tagRanges = mutableStateListOf() // Transformations coordinate state internally val inputTransformation = TagInputTransformation(tagRanges) val outputTransformation = TagOutputTransformation(tagRanges) fun addTag(tag: String) { ... } } ``` [10:22](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=622s) And then, they can all reuse the intermediate calculations like `tagRanges` in this case. [10:30](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=630s) And then, you simply pass this state holder object to your composable. ```kotlin @Stable class TagEditorState(val textFieldState: TextFieldState = TextFieldState()) {} @Composable fun PostEditor(state: TagEditorState = rememberTagEditorState()) { BasicTextField( state = state.textFieldState, inputTransformation = state.inputTransformation, outputTransformation = state.outputTransformation, ) } ``` [10:36](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=636s) 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`. [10:51](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=651s) 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. ```kotlin data class FormUiState(val username: TextFieldState) ``` [11:05](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=665s) `TextFieldState` is a long-lived, mutable object backed by the Snapshot System, and its reference object never changes. [11:15](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=675s) So, your `StateFlow` won't even know that user is typing at the moment because the reference stays the same. [11:22](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=682s) 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. [11:32](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=692s) So, that will be like selection, or cursor position, or undo/redo history. [11:38](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=698s) So instead, make make `TextFieldState` a property directly on your `ViewModel`. ```kotlin class FormViewModel : ViewModel() { val username = TextFieldState() val uiState: StateFlow = ... } ``` [11:45](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=705s) 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. [12:00](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=720s) It's just a pure Snapshot-Backed state holder object. [12:04](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=724s) So, in that case, it's kind of conceptually no different from `StateFlow`. [12:09](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=729s) 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. ```kotlin class FormViewModel(savedStateHandle: SavedStateHandle) : ViewModel() { val username: TextFieldState = savedStateHandle.saveable( key = "username", saver = TextFieldState.Saver, init = { TextFieldState() } ) val uiState: StateFlow = ... } ``` [12:25](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=745s) 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? [12:37](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=757s) Or for instance, you're migrating a complex legacy screen, and you can't update or and refactor UI state today? [12:45](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=765s) In that case, use the conforming approach. [12:49](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=769s) So, you keep the `TextFieldState` in the composable as a single source of truth for that text field. [12:55](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=775s) But then, you use `LaunchedEffect` with a `snapshotFlow` to push that text changes back into your existing `ViewModel`. ```kotlin @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) } ``` [13:03](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=783s) 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. [13:21](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=801s) Yeah, and watch out for a common trap here. ```kotlin @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) } ``` [13:23](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=803s) In most cases, you want to omit the first event. [13:28](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=808s) This will prevent unnecessarily duplicated search queries, or it will prevent also overwriting your `ViewModel` with the initial value. [13:39](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=819s) 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`. ```kotlin @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) } ``` [13:57](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=837s) 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. [14:12](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=852s) To assist you even further with the transition, we wrote this migration guide. [14:15](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=855s) You can access it via this link. [14:17](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=857s) 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 [14:31](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=871s) And everything we've covered so far, `TextFieldState`, TextFieldBuffer, input and output transformation, weren't built just to just for plain text input. [14:39](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=879s) These are this is the foundation that powers our newest set of APIs for formatted text editing. [14:48](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=888s) So now over to Haoyu to tell you more about this. [14:51](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=891s) All right. [14:52](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=892s) Thank you. [14:56](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=896s) Hello, everyone. [14:57](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=897s) Next, I will talk about new APIs we've introduced for formatted text editing. [15:02](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=902s) So, before we dive into the new APIs, let's take a step back and look how the styling works in static text. [15:09](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=909s) So most of you already know AnnotatedString really well. ```kotlin 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) ``` [15:13](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=913s) It's the standard way to styling a static text. [15:16](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=916s) We use `buildAnnotatedString` and call add-- call `addStyle` to apply `SpanStyle` or `ParagraphStyle` on certain given ranges. [15:25](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=925s) It works well for the static text. [15:28](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=928s) So when look-- when we look at formatted text editing, our initial thought was, can we just make AnnotatedString editable? [15:35](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=935s) But then we quickly realized that's not very simple. [15:38](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=938s) AnnotatedString is an immutable data structure. [15:43](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=943s) But in the editing world, user is constantly typing. [15:47](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=947s) And with every key-- every keystroke, we have to allocate a new AnnotatedString object. [15:53](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=953s) And on each AnnotatedString, there are a lot of `AnnotatedString.Range`. [15:58](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=958s) So those objects has to be recreated as well. [16:00](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=960s) It doesn't scale very well and cause significant allocation overhead. [16:06](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=966s) So, check this example. [16:08](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=968s) When we insert the word in the middle, every style after it has shifted its range and get reallocated. [16:14](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=974s) This clearly doesn't work when you have a lot of styles. [16:18](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=978s) So instead, we've introduced dedicated styling APIs inside the TextFieldBuffer that Anastasia introduced before. [16:26](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=986s) They're very similar to the styling APIs in AnnotatedString. [16:31](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=991s) Let's dive into them how they work. [16:36](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=996s) So just like AnnotatedString.Builder, TextFieldBuffer now has `addStyle` to apply `SpanStyle` or `ParagraphStyle` to a range. ```kotlin 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 fun addStyle(paragraphStyle: ParagraphStyle, start: Int, end: Int, expandPolicy: ExpandPolicy): TrackedRange ``` [16:44](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1004s) You can also pass an `ExpandPolicy` parameter, which controls how the style behaves when text is typed right at the style boundaries. [16:54](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1014s) We'll look at that in a moment. [16:57](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1017s) So first, let's look at how the style tracking works. ```kotlin 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) } ``` [17:00](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1020s) Once you add a style to the TextFieldBuffer, its range updates automatically as the text is edited. [17:08](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1028s) In this example, we start by making some text bold. [17:12](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1032s) When you insert text before it, the style range shifts forward. [17:17](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1037s) And when you insert text inside it, the text range expands. [17:21](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1041s) And when we delete text inside the range-- the style range shrinks accordingly. [17:27](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1047s) And if all the text inside the style range is deleted, the style collapses and is automatically removed from the buffer. [17:36](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1056s) So now what happens when the text is inserted right at the boundaries of a style range? [17:41](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1061s) Is the newly inserted text covered by the style or not? [17:45](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1065s) So, to let you control this, we've introduced `ExpandPolicy` APIs. [17:49](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1069s) Let's look-- let's walk through them. [17:52](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1072s) So the first is `ExpandPolicy.InsideOnly`. ```kotlin 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") ``` [17:55](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1075s) In this mode, inserting text at the boundary will not expand the style ranges. [18:01](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1081s) Here we first apply a bold range. [18:04](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1084s) If we insert text at the start of the bold range, the style range shifts, but doesn't expand. [18:10](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1090s) Similarly, if we insert text at the end of the bold range, the style range doesn't expand either. [18:17](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1097s) So here's a demo showing how it works in an editor. [18:22](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1102s) So next is `ExpandPolicy.AtStart`. ```kotlin val state = TextFieldState("The fox") state.edit { addStyle( SpanStyle(fontWeight = FontWeight.Bold), start = 4, end = 7, policy = ExpandPolicy.AtStart ) insert(4, "quick ") ``` [18:24](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1104s) As the name suggest, inserting text at the start of the range expand the style range. [18:29](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1109s) So when we insert text right before the style range expands to cover the newly inserted text. [18:36](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1116s) So here's another demo showing how it works. [18:40](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1120s) Third is `ExpandPolicy.AtEnd`. ```kotlin val state = TextFieldState("The fox") state.edit { addStyle( SpanStyle(fontWeight = FontWeight.Bold), start = 4, end = 7, policy = ExpandPolicy.AtEnd ) insert(length, " jumps") ``` [18:42](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1122s) So we're inserting text at the end of the range expand the style range. [18:47](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1127s) So this is also the most common, setting for the editors. [18:53](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1133s) So here's a demo showing how it works. [18:56](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1136s) 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. ```kotlin 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") } ``` [19:07](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1147s) So another demo for this. [19:11](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1151s) We covered all the styling-applying APIs so far. [19:14](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1154s) In addition to that, we also introduced new query APIs like `getSpanStyles`, `getParagraphStyles` to fetch the styles currently applied in a given range. ```kotlin // Outside edits. ReadOnly API in TextFieldState. val textFieldState.styles: TextFieldTextStyles fun TextFieldTextStyles.getSpanStyle(range: TextRange): List> // Inside edits, returned TrackedRange fun TextFieldBuffer.getSpanStyles(range: TextRange): List> ``` [19:25](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1165s) Here are two versions of the query APIs. [19:28](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1168s) On the `TextFieldState`, the style query APIs are read-only. [19:32](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1172s) They return a list of immutable `AnnotatedString.Range` objects. [19:36](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1176s) And but in the TextFieldBuffer, `getSpanStyles` returned a list of newly introduced `TrackedRange` objects, which I will introduce later. [19:45](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1185s) So all style queries are order aware. [19:48](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1188s) They return the style ranges exactly the order when the style is applied. [19:52](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1192s) So this allows you to apply, just like AnnotatedString, to apply another style on top of an existing style to overwrite the earlier ones. [20:03](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1203s) So next is `TrackedRange` API. ```kotlin var TrackedRange<*>.textRange: TextRange var TrackedRange<*>.expandPolicy: ExpandPolicy var TrackedRange.spanStyle: SpanStyle var TrackedRange.paragraphStyle: ParagraphStyle val TrackedRange<*>.isValid: Boolean fun removeStyle(trackedRange: TrackedRange<*>): Boolean ``` [20:04](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1204s) `TrackedRange` objects are returned by `addStyle` or `getSpanStyles` APIs in TextFieldBuffer. [20:10](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1210s) Think of it as a live handle of the style range in the buffer. [20:15](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1215s) You can use it to update the text range, `ExpandPolicy` or the style itself in the place. [20:21](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1221s) It also has an `isValid` property to check if the style range is still in the TextFieldBuffer. [20:27](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1227s) This is because style can be removed if the-- if it's collapsed during the editing, or it gets removed deliberately by the `removeStyle`. [20:39](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1239s) So next let's see how those APIs come together in a real-world use cases. ### Building a WYSIWYG editor [20:45](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1245s) The first use case is build What You See Is What You Get, WYSIWYG Editor. [20:49](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1249s) In this example, we implemented the bold button feature. [20:53](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1253s) Clicking the bold button toggles the bold style on the selected text. [20:58](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1258s) So next, let's look at the code. [21:01](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1261s) So when the user taps the bold button, we first call `getSpanStyles` on a selection to find all the overlapping styles. ```kotlin 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()) } } ``` [21:08](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1268s) And then we check if the entire selection is already bold or not. [21:12](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1272s) Here, the selected word is not bold. [21:15](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1275s) Since it's not bold, we `addStyle` to the selected range. [21:18](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1278s) We pass `ExpandPolicy.AtEnd`, so that user can continue typing the bold right after this word. [21:26](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1286s) Now, let's look what happens if the selected range is already bold. ```kotlin fun TextFieldBuffer.unboldSelection( boldSpan: TrackedRange ) { 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) } } ``` [21:30](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1290s) In that case, we need to clear the bold style from the selected range. [21:34](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1294s) Here, the user-selected the word is inside the longer bold phrase. [21:38](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1298s) Because the bold style extends before the selection, we need to update the style range, leaving the selected word unbolded. [21:46](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1306s) To do this, we can use a `TrackedRange` API. [21:50](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1310s) 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. [22:03](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1323s) Of course, this is a highly simplified implementation. [22:07](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1327s) For a full demo, you can check our Compose samples repository. [22:12](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1332s) And next, let's look at a pattern-based styling. [22:16](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1336s) In this demo, when user types Markdown asterisks around a word, the editor automatically converts it into bold text as they type. [22:27](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1347s) Now, let's see how it's implemented using `InputTransformation`. ```kotlin 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 ) } } } ``` [22:31](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1351s) In our transforming input filter, we find all the matching patterns over the button. [22:38](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1358s) And for each match, we extract the content inside the double asterisks. [22:42](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1362s) In this example, it's the word "fox". [22:46](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1366s) And next, we remove the asterisks and replace the matching pattern with its content. [22:53](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1373s) And finally, we call `addStyle` to the bold to apply the bold style to the content text. [22:59](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1379s) Just remember, `InputTransformation` will submit to the final text field state. [23:05](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1385s) This means the bold style is applied to the state persistently. [23:11](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1391s) And finally, let's look at the search highlighting example. [23:14](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1394s) 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. [23:26](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1406s) Now, let's look how it's implemented in the `OutputTransformation`. [23:28](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1408s) When highlighting a search word, you should never mutate the text state. [23:35](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1415s) And `OutputTransformation` is a perfect place to implement this use case. [23:40](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1420s) So let's check the actual implementation. [23:42](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1422s) We first search the highlighting words and find all the matches. ```kotlin class SearchHighlightTransformation( val query: MutableState ) : 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) } } } ``` [23:47](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1427s) And note that the query is a mutable state. [23:49](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1429s) This is because the `OutputTransformation` is snapshot-aware. [23:54](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1434s) When this mutable states gets updated, the `OutputTransformation` is reapplied automatically. [24:01](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1441s) And then when we loops through every matches and call `addStyle` to draw the yellow highlights over the match range. [24:08](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1448s) Again, `OutputTransformation` only change the visual text, and the original text states are untouched. [24:16](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1456s) Okay, that's all the use cases we have. [24:20](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1460s) Just to remind you, all the APIs showed before are available starting from Compose 1.12. ### Upcoming markup text library [24:27](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1467s) And to wrap up, I want to share a sneak peek of the new project we're currently working on. [24:32](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1472s) It's a markup text library for Jetpack Compose, which support rich text formats like Markdown. [24:41](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1481s) One of the most exciting capabilities we're supporting is incremental parsing and incremental rendering. [24:48](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1488s) So it's very suitable for live editing or the Markdown streaming. [24:51](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1491s) 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. [25:02](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1502s) If this is something you are interested, or you have some use case in your app, please feel free to reach out to us. [25:10](https://www.youtube.com/watch?v=1DvjkRYGs2g&t=1510s) And thank you, everyone.