Merge "Add extensions to TextFieldState for observing changes." into androidx-main
diff --git a/compose/foundation/foundation/api/public_plus_experimental_current.txt b/compose/foundation/foundation/api/public_plus_experimental_current.txt
index 3954218..d5783f1 100644
--- a/compose/foundation/foundation/api/public_plus_experimental_current.txt
+++ b/compose/foundation/foundation/api/public_plus_experimental_current.txt
@@ -1443,7 +1443,7 @@
field public static final androidx.compose.foundation.text2.input.TextFieldLineLimits.SingleLine INSTANCE;
}
- @androidx.compose.foundation.ExperimentalFoundationApi public final class TextFieldState {
+ @androidx.compose.foundation.ExperimentalFoundationApi @androidx.compose.runtime.Stable public final class TextFieldState {
ctor public TextFieldState(optional String initialText, optional long initialSelectionInChars);
method public inline void edit(kotlin.jvm.functions.Function1<? super androidx.compose.foundation.text2.input.TextFieldBuffer,? extends androidx.compose.foundation.text2.input.TextEditResult> block);
method public androidx.compose.foundation.text2.input.TextFieldCharSequence getText();
@@ -1457,9 +1457,11 @@
}
public final class TextFieldStateKt {
+ method @androidx.compose.foundation.ExperimentalFoundationApi public static suspend Object? forEachTextValue(androidx.compose.foundation.text2.input.TextFieldState, kotlin.jvm.functions.Function2<? super androidx.compose.foundation.text2.input.TextFieldCharSequence,? super kotlin.coroutines.Continuation<? super kotlin.Unit>,?> block, kotlin.coroutines.Continuation<?>);
method @androidx.compose.foundation.ExperimentalFoundationApi @androidx.compose.runtime.Composable public static androidx.compose.foundation.text2.input.TextFieldState rememberTextFieldState();
method @androidx.compose.foundation.ExperimentalFoundationApi public static void setTextAndPlaceCursorAtEnd(androidx.compose.foundation.text2.input.TextFieldState, String text);
method @androidx.compose.foundation.ExperimentalFoundationApi public static void setTextAndSelectAll(androidx.compose.foundation.text2.input.TextFieldState, String text);
+ method @androidx.compose.foundation.ExperimentalFoundationApi public static kotlinx.coroutines.flow.Flow<androidx.compose.foundation.text2.input.TextFieldCharSequence> textAsFlow(androidx.compose.foundation.text2.input.TextFieldState);
}
@androidx.compose.foundation.ExperimentalFoundationApi @kotlin.jvm.JvmInline public final value class TextObfuscationMode {
diff --git a/compose/foundation/foundation/samples/src/main/java/androidx/compose/foundation/samples/BasicTextField2Samples.kt b/compose/foundation/foundation/samples/src/main/java/androidx/compose/foundation/samples/BasicTextField2Samples.kt
index 4052364..507a250 100644
--- a/compose/foundation/foundation/samples/src/main/java/androidx/compose/foundation/samples/BasicTextField2Samples.kt
+++ b/compose/foundation/foundation/samples/src/main/java/androidx/compose/foundation/samples/BasicTextField2Samples.kt
@@ -15,41 +15,151 @@
*/
@file:OptIn(ExperimentalFoundationApi::class, ExperimentalFoundationApi::class)
+@file:Suppress("UNUSED_PARAMETER", "unused", "LocalVariableName", "RedundantSuspendModifier")
package androidx.compose.foundation.samples
import androidx.annotation.Sampled
import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text2.BasicTextField2
import androidx.compose.foundation.text2.input.TextEditFilter
import androidx.compose.foundation.text2.input.TextFieldState
import androidx.compose.foundation.text2.input.delete
import androidx.compose.foundation.text2.input.forEachChange
import androidx.compose.foundation.text2.input.forEachChangeReversed
+import androidx.compose.foundation.text2.input.forEachTextValue
import androidx.compose.foundation.text2.input.insert
import androidx.compose.foundation.text2.input.rememberTextFieldState
import androidx.compose.foundation.text2.input.selectCharsIn
+import androidx.compose.foundation.text2.input.setTextAndPlaceCursorAtEnd
+import androidx.compose.foundation.text2.input.textAsFlow
import androidx.compose.foundation.text2.input.then
+import androidx.compose.material.Icon
+import androidx.compose.material.IconButton
+import androidx.compose.material.Text
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Clear
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextRange
+import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.substring
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.debounce
+
+@Sampled
+fun BasicTextField2StateCompleteSample() {
+ class SearchViewModel(
+ val searchFieldState: TextFieldState = TextFieldState()
+ ) {
+ private val queryValidationRegex = """\w+""".toRegex()
+
+ // Use derived state to avoid recomposing every time the text changes, and only recompose
+ // when the input becomes valid or invalid.
+ val isQueryValid by derivedStateOf {
+ // This lambda will be re-executed every time inputState.text changes.
+ searchFieldState.text.matches(queryValidationRegex)
+ }
+
+ var searchResults: List<String> by mutableStateOf(emptyList())
+ private set
+
+ /** Called while the view model is active, e.g. from a LaunchedEffect. */
+ suspend fun run() {
+ searchFieldState.forEachTextValue { queryText ->
+ // Start a new search every time the user types something valid. If the previous
+ // search is still being processed when the text is changed, it will be cancelled
+ // and this code will run again with the latest query text.
+ if (isQueryValid) {
+ searchResults = performSearch(query = queryText)
+ }
+ }
+ }
+
+ fun clearQuery() {
+ searchFieldState.setTextAndPlaceCursorAtEnd("")
+ }
+
+ private suspend fun performSearch(query: CharSequence): List<String> {
+ TODO()
+ }
+ }
+
+ @Composable
+ fun SearchScreen(viewModel: SearchViewModel) {
+ Column {
+ Row {
+ BasicTextField2(viewModel.searchFieldState)
+ IconButton(onClick = { viewModel.clearQuery() }) {
+ Icon(Icons.Default.Clear, contentDescription = "clear search query")
+ }
+ }
+ if (!viewModel.isQueryValid) {
+ Text("Invalid query", style = TextStyle(color = Color.Red))
+ }
+ LazyColumn {
+ items(viewModel.searchResults) {
+ TODO()
+ }
+ }
+ }
+ }
+}
+
+@Sampled
+fun BasicTextField2TextDerivedStateSample() {
+ class ViewModel {
+ private val inputValidationRegex = """\w+""".toRegex()
+
+ val inputState = TextFieldState()
+
+ // Use derived state to avoid recomposing every time the text changes, and only recompose
+ // when the input becomes valid or invalid.
+ val isInputValid by derivedStateOf {
+ // This lambda will be re-executed every time inputState.text changes.
+ inputState.text.matches(inputValidationRegex)
+ }
+ }
+
+ @Composable
+ fun Screen(viewModel: ViewModel) {
+ Column {
+ BasicTextField2(viewModel.inputState)
+ if (!viewModel.isInputValid) {
+ Text("Input is invalid.", style = TextStyle(color = Color.Red))
+ }
+ }
+ }
+}
@Sampled
fun BasicTextField2StateEditSample() {
- val state = TextFieldState("hello world")
+ val state = TextFieldState("hello world!")
state.edit {
// Insert a comma after "hello".
- replace(5, 5, ",") // = "hello, world"
+ insert(5, ",") // = "hello, world!"
- // Delete "world".
- replace(7, 12, "") // = "hello, "
+ // Delete the exclamation mark.
+ delete(12, 13) // = "hello, world"
// Add a different name.
append("Compose") // = "hello, Compose"
+ // Say goodbye.
+ replace(0, 5, "goodbye") // "goodbye, Compose"
+
// Select the new name so the user can change it by just starting to type.
- selectCharsIn(TextRange(7, 14)) // "hello, ̲C̲o̲m̲p̲o̲s̲e"
+ selectCharsIn(TextRange(9, 16)) // "goodbye, ̲C̲o̲m̲p̲o̲s̲e"
}
}
@@ -112,8 +222,8 @@
printECountFilter.then(removeFirstEFilter)
}
-@Composable
@Sampled
+@Composable
fun BasicTextField2ChangeIterationSample() {
// Print a log message every time the text is changed.
BasicTextField2(state = rememberTextFieldState(), filter = { _, new ->
@@ -124,8 +234,8 @@
})
}
-@Composable
@Sampled
+@Composable
fun BasicTextField2ChangeReverseIterationSample() {
// Make a text field behave in "insert mode" – inserted text overwrites the text ahead of it
// instead of being inserted.
@@ -140,4 +250,78 @@
}
}
})
+}
+
+@Sampled
+fun BasicTextField2ForEachTextValueSample() {
+ class SearchViewModel {
+ val searchFieldState = TextFieldState()
+ var searchResults: List<String> by mutableStateOf(emptyList())
+ private set
+
+ /** Called while the view model is active, e.g. from a LaunchedEffect. */
+ suspend fun run() {
+ searchFieldState.forEachTextValue { queryText ->
+ // Start a new search every time the user types something. If the previous search
+ // is still being processed when the text is changed, it will be cancelled and this
+ // code will run again with the latest query text.
+ searchResults = performSearch(query = queryText)
+ }
+ }
+
+ private suspend fun performSearch(query: CharSequence): List<String> {
+ TODO()
+ }
+ }
+
+ @Composable
+ fun SearchScreen(viewModel: SearchViewModel) {
+ Column {
+ BasicTextField2(viewModel.searchFieldState)
+ LazyColumn {
+ items(viewModel.searchResults) {
+ TODO()
+ }
+ }
+ }
+ }
+}
+
+@OptIn(FlowPreview::class)
+@Suppress("RedundantSuspendModifier")
+@Sampled
+fun BasicTextField2TextValuesSample() {
+ class SearchViewModel {
+ val searchFieldState = TextFieldState()
+ var searchResults: List<String> by mutableStateOf(emptyList())
+ private set
+
+ /** Called while the view model is active, e.g. from a LaunchedEffect. */
+ suspend fun run() {
+ searchFieldState.textAsFlow()
+ // Let fast typers get multiple keystrokes in before kicking off a search.
+ .debounce(500)
+ // collectLatest cancels the previous search if it's still running when there's a
+ // new change.
+ .collectLatest { queryText ->
+ searchResults = performSearch(query = queryText)
+ }
+ }
+
+ private suspend fun performSearch(query: CharSequence): List<String> {
+ TODO()
+ }
+ }
+
+ @Composable
+ fun SearchScreen(viewModel: SearchViewModel) {
+ Column {
+ BasicTextField2(viewModel.searchFieldState)
+ LazyColumn {
+ items(viewModel.searchResults) {
+ TODO()
+ }
+ }
+ }
+ }
}
\ No newline at end of file
diff --git a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text2/input/TextFieldState.kt b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text2/input/TextFieldState.kt
index 9cd5e6a..442a7dd 100644
--- a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text2/input/TextFieldState.kt
+++ b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text2/input/TextFieldState.kt
@@ -21,25 +21,55 @@
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.text2.input.internal.EditProcessor
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Stable
import androidx.compose.runtime.saveable.SaverScope
import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.text.TextRange
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.collectLatest
/**
- * The editable text state of a text field, including both the text itself and position of the
+ * The editable text state of a text field, including both the [text] itself and position of the
* cursor or selection.
*
- * To change the state, call [edit].
+ * To change the text field contents programmatically, call [edit], [setTextAndSelectAll], or
+ * [setTextAndPlaceCursorAtEnd]. To observe the value of the field over time, call
+ * [forEachTextValue] or [textAsFlow].
+ *
+ * When instantiating this class from a composable, use [rememberTextFieldState] to automatically
+ * save and restore the field state. For more advanced use cases, pass [TextFieldState.Saver] to
+ * [rememberSaveable].
+ *
+ * @sample androidx.compose.foundation.samples.BasicTextField2StateCompleteSample
*/
@ExperimentalFoundationApi
+@Stable
class TextFieldState(
initialText: String = "",
initialSelectionInChars: TextRange = TextRange.Zero
) {
-
internal var editProcessor =
EditProcessor(TextFieldCharSequence(initialText, initialSelectionInChars))
+ /**
+ * The current text and selection. This value will automatically update when the user enters
+ * text or otherwise changes the text field contents. To change it programmatically, call
+ * [edit].
+ *
+ * This is backed by snapshot state, so reading this property in a restartable function (e.g.
+ * a composable function) will cause the function to restart when the text field's value
+ * changes.
+ *
+ * To observe changes to this property outside a restartable function, see [forEachTextValue]
+ * and [textValues].
+ *
+ * @sample androidx.compose.foundation.samples.BasicTextField2TextDerivedStateSample
+ *
+ * @see edit
+ * @see forEachTextValue
+ * @see textValues
+ */
val text: TextFieldCharSequence
get() = editProcessor.value
@@ -50,6 +80,7 @@
* [TextEditResult] for the full list of prebuilt results).
*
* @sample androidx.compose.foundation.samples.BasicTextField2StateEditSample
+ *
* @see setTextAndPlaceCursorAtEnd
* @see setTextAndSelectAll
*/
@@ -60,7 +91,7 @@
}
override fun toString(): String =
- "TextFieldState(selection=${text.selectionInChars}, text=\"$text\")"
+ "TextFieldState(selectionInChars=${text.selectionInChars}, text=\"$text\")"
@Suppress("ShowingMemberInHiddenClass")
@PublishedApi
@@ -103,6 +134,15 @@
}
/**
+ * Returns a [Flow] of the values of [TextFieldState.text] as seen from the global snapshot.
+ * The initial value is emitted immediately when the flow is collected.
+ *
+ * @sample androidx.compose.foundation.samples.BasicTextField2TextValuesSample
+ */
+@ExperimentalFoundationApi
+fun TextFieldState.textAsFlow(): Flow<TextFieldCharSequence> = snapshotFlow { text }
+
+/**
* Create and remember a [TextFieldState]. The state is remembered using [rememberSaveable] and so
* will be saved and restored with the composition.
*
@@ -144,6 +184,27 @@
}
}
+/**
+ * Invokes [block] with the value of [TextFieldState.text], and every time the value is changed.
+ *
+ * The caller will be suspended until its coroutine is cancelled. If the text is changed while
+ * [block] is suspended, [block] will be cancelled and re-executed with the new value immediately.
+ * [block] will never be executed concurrently with itself.
+ *
+ * To get access to a [Flow] of [TextFieldState.text] over time, use [textAsFlow].
+ *
+ * @sample androidx.compose.foundation.samples.BasicTextField2ForEachTextValueSample
+ *
+ * @see textAsFlow
+ */
+@ExperimentalFoundationApi
+suspend fun TextFieldState.forEachTextValue(
+ block: suspend (TextFieldCharSequence) -> Unit
+): Nothing {
+ textAsFlow().collectLatest(block)
+ error("textAsFlow expected not to complete without exception")
+}
+
@OptIn(ExperimentalFoundationApi::class)
internal fun TextFieldState.deselect() {
if (!text.selectionInChars.collapsed) {
diff --git a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text2/input/internal/EditProcessor.kt b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text2/input/internal/EditProcessor.kt
index e2b57a2..a089951 100644
--- a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text2/input/internal/EditProcessor.kt
+++ b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text2/input/internal/EditProcessor.kt
@@ -20,6 +20,7 @@
import androidx.compose.foundation.text2.input.TextEditFilter
import androidx.compose.foundation.text2.input.TextFieldBufferWithSelection
import androidx.compose.foundation.text2.input.TextFieldCharSequence
+import androidx.compose.runtime.State
import androidx.compose.runtime.collection.mutableVectorOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -43,13 +44,17 @@
initialValue: TextFieldCharSequence = TextFieldCharSequence("", TextRange.Zero),
) {
+ private val valueMutableState = mutableStateOf(initialValue)
+
/**
* The current state of the internal editing buffer as a [TextFieldCharSequence] backed by
* snapshot state, so its readers can get updates in composition context.
*/
- var value: TextFieldCharSequence by mutableStateOf(initialValue)
+ var value: TextFieldCharSequence by valueMutableState
private set
+ val valueState: State<TextFieldCharSequence> get() = valueMutableState
+
// The editing buffer used for applying editor commands from IME.
internal var mBuffer: EditingBuffer = EditingBuffer(
text = initialValue.toString(),
diff --git a/compose/foundation/foundation/src/test/kotlin/androidx/compose/foundation/text2/input/TextFieldStateTest.kt b/compose/foundation/foundation/src/test/kotlin/androidx/compose/foundation/text2/input/TextFieldStateTest.kt
index d068e21..15de948 100644
--- a/compose/foundation/foundation/src/test/kotlin/androidx/compose/foundation/text2/input/TextFieldStateTest.kt
+++ b/compose/foundation/foundation/src/test/kotlin/androidx/compose/foundation/text2/input/TextFieldStateTest.kt
@@ -17,14 +17,23 @@
package androidx.compose.foundation.text2.input
import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.runtime.snapshots.Snapshot
import androidx.compose.ui.text.TextRange
import com.google.common.truth.Truth.assertThat
import kotlin.test.assertFailsWith
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.awaitCancellation
+import kotlinx.coroutines.cancelChildren
+import kotlinx.coroutines.job
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.JUnit4
-@OptIn(ExperimentalFoundationApi::class)
+@OptIn(ExperimentalFoundationApi::class, ExperimentalCoroutinesApi::class)
@RunWith(JUnit4::class)
class TextFieldStateTest {
@@ -316,4 +325,190 @@
placeCursorAtEnd()
}
}
+
+ @Test
+ fun forEachValues_fires_immediately() = runTestWithSnapshotsThenCancelChildren {
+ val state = TextFieldState("hello", initialSelectionInChars = TextRange(5))
+ val texts = mutableListOf<TextFieldCharSequence>()
+
+ launch(Dispatchers.Unconfined) {
+ state.forEachTextValue { texts += it }
+ }
+
+ assertThat(texts).hasSize(1)
+ assertThat(texts.single()).isSameInstanceAs(state.text)
+ assertThat(texts.single().toString()).isEqualTo("hello")
+ assertThat(texts.single().selectionInChars).isEqualTo(TextRange(5))
+ }
+
+ @Test
+ fun forEachValue_fires_whenTextChanged() = runTestWithSnapshotsThenCancelChildren {
+ val state = TextFieldState(initialSelectionInChars = TextRange(0))
+ val texts = mutableListOf<TextFieldCharSequence>()
+ val initialText = state.text
+
+ launch(Dispatchers.Unconfined) {
+ state.forEachTextValue { texts += it }
+ }
+
+ state.edit {
+ append("hello")
+ placeCursorBeforeCharAt(0)
+ }
+
+ assertThat(texts).hasSize(2)
+ assertThat(texts.last()).isSameInstanceAs(state.text)
+ assertThat(texts.last().toString()).isEqualTo("hello")
+ assertThat(texts.last().selectionInChars).isEqualTo(initialText.selectionInChars)
+ }
+
+ @Test
+ fun forEachValue_fires_whenSelectionChanged() = runTestWithSnapshotsThenCancelChildren {
+ val state = TextFieldState("hello", initialSelectionInChars = TextRange(0))
+ val texts = mutableListOf<TextFieldCharSequence>()
+
+ launch(Dispatchers.Unconfined) {
+ state.forEachTextValue { texts += it }
+ }
+
+ state.edit {
+ placeCursorAtEnd()
+ }
+
+ assertThat(texts).hasSize(2)
+ assertThat(texts.last()).isSameInstanceAs(state.text)
+ assertThat(texts.last().toString()).isEqualTo("hello")
+ assertThat(texts.last().selectionInChars).isEqualTo(TextRange(5))
+ }
+
+ @Test
+ fun forEachValue_firesTwice_whenEditCalledTwice() = runTestWithSnapshotsThenCancelChildren {
+ val state = TextFieldState()
+ val texts = mutableListOf<TextFieldCharSequence>()
+
+ launch(Dispatchers.Unconfined) {
+ state.forEachTextValue { texts += it }
+ }
+
+ state.edit {
+ append("hello")
+ placeCursorAtEnd()
+ }
+
+ state.edit {
+ append(" world")
+ placeCursorAtEnd()
+ }
+
+ assertThat(texts).hasSize(3)
+ assertThat(texts[1].toString()).isEqualTo("hello")
+ assertThat(texts[2]).isSameInstanceAs(state.text)
+ assertThat(texts[2].toString()).isEqualTo("hello world")
+ }
+
+ @Test
+ fun forEachValue_firesOnce_whenMultipleChangesMadeInSingleEdit() =
+ runTestWithSnapshotsThenCancelChildren {
+ val state = TextFieldState()
+ val texts = mutableListOf<TextFieldCharSequence>()
+
+ launch(Dispatchers.Unconfined) {
+ state.forEachTextValue { texts += it }
+ }
+
+ state.edit {
+ append("hello")
+ append(" world")
+ placeCursorAtEnd()
+ }
+
+ assertThat(texts.last()).isSameInstanceAs(state.text)
+ assertThat(texts.last().toString()).isEqualTo("hello world")
+ }
+
+ @Test
+ fun forEachValue_fires_whenChangeMadeInSnapshotIsApplied() =
+ runTestWithSnapshotsThenCancelChildren {
+ val state = TextFieldState()
+ val texts = mutableListOf<TextFieldCharSequence>()
+
+ launch(Dispatchers.Unconfined) {
+ state.forEachTextValue { texts += it }
+ }
+
+ val snapshot = Snapshot.takeMutableSnapshot()
+ snapshot.enter {
+ state.edit {
+ append("hello")
+ placeCursorAtEnd()
+ }
+ assertThat(texts.isEmpty())
+ }
+ assertThat(texts.isEmpty())
+
+ snapshot.apply()
+ snapshot.dispose()
+
+ assertThat(texts.last()).isSameInstanceAs(state.text)
+ }
+
+ @Test
+ fun forEachValue_notFired_whenChangeMadeInSnapshotThenDisposed() =
+ runTestWithSnapshotsThenCancelChildren {
+ val state = TextFieldState()
+ val texts = mutableListOf<TextFieldCharSequence>()
+
+ launch(Dispatchers.Unconfined) {
+ state.forEachTextValue { texts += it }
+ }
+
+ val snapshot = Snapshot.takeMutableSnapshot()
+ snapshot.enter {
+ state.edit {
+ append("hello")
+ placeCursorAtEnd()
+ }
+ }
+ snapshot.dispose()
+
+ // Only contains initial value.
+ assertThat(texts).hasSize(1)
+ assertThat(texts.single().toString()).isEmpty()
+ }
+
+ @Test
+ fun forEachValue_cancelsPreviousHandler_whenChangeMadeWhileSuspended() =
+ runTestWithSnapshotsThenCancelChildren {
+ val state = TextFieldState()
+ val texts = mutableListOf<TextFieldCharSequence>()
+
+ launch(Dispatchers.Unconfined) {
+ state.forEachTextValue {
+ texts += it
+ awaitCancellation()
+ }
+ }
+
+ state.setTextAndPlaceCursorAtEnd("hello")
+ state.setTextAndPlaceCursorAtEnd("world")
+
+ assertThat(texts.map { it.toString() })
+ .containsExactly("", "hello", "world")
+ .inOrder()
+ }
+
+ private fun runTestWithSnapshotsThenCancelChildren(testBody: suspend TestScope.() -> Unit) {
+ val globalWriteObserverHandle = Snapshot.registerGlobalWriteObserver {
+ // This is normally done by the compose runtime.
+ Snapshot.sendApplyNotifications()
+ }
+ try {
+ runTest {
+ testBody()
+ coroutineContext.job.cancelChildren()
+ }
+ } finally {
+ globalWriteObserverHandle.dispose()
+ }
+ }
}
\ No newline at end of file