diff --git a/samples/compose-samples/src/androidTest/java/com/squareup/sample/compose/textinput/TextInputTest.kt b/samples/compose-samples/src/androidTest/java/com/squareup/sample/compose/textinput/TextInputTest.kt index a62131b69c..7fbcae4d00 100644 --- a/samples/compose-samples/src/androidTest/java/com/squareup/sample/compose/textinput/TextInputTest.kt +++ b/samples/compose-samples/src/androidTest/java/com/squareup/sample/compose/textinput/TextInputTest.kt @@ -1,6 +1,5 @@ package com.squareup.sample.compose.textinput -import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.hasSetTextAction import androidx.compose.ui.test.junit4.createAndroidComposeRule @@ -30,7 +29,6 @@ class TextInputTest { .around(composeRule) .around(IdlingDispatcherRule) - @OptIn(ExperimentalTestApi::class) @Test fun allowsTextEditing() { runBlocking { diff --git a/samples/compose-samples/src/main/java/com/squareup/sample/compose/textinput/TextInputViewFactory.kt b/samples/compose-samples/src/main/java/com/squareup/sample/compose/textinput/TextInputViewFactory.kt index 0a22e4c823..309bef1da0 100644 --- a/samples/compose-samples/src/main/java/com/squareup/sample/compose/textinput/TextInputViewFactory.kt +++ b/samples/compose-samples/src/main/java/com/squareup/sample/compose/textinput/TextInputViewFactory.kt @@ -19,7 +19,7 @@ import androidx.compose.ui.unit.dp import com.squareup.sample.compose.textinput.TextInputWorkflow.Rendering import com.squareup.workflow1.ui.TextController import com.squareup.workflow1.ui.compose.ScreenComposableFactory -import com.squareup.workflow1.ui.compose.asMutableState +import com.squareup.workflow1.ui.compose.asMutableTextFieldValueState import com.squareup.workflow1.ui.compose.tooling.Preview val TextInputComposableFactory = ScreenComposableFactory { rendering -> @@ -30,14 +30,14 @@ val TextInputComposableFactory = ScreenComposableFactory { rendering .animateContentSize(), horizontalAlignment = Alignment.CenterHorizontally ) { - var text by rendering.textController.asMutableState() + var textFieldValue by rendering.textController.asMutableTextFieldValueState() - Text(text = text) + Text(text = textFieldValue.text) OutlinedTextField( label = {}, placeholder = { Text("Enter some text") }, - value = text, - onValueChange = { text = it } + value = textFieldValue, + onValueChange = { textFieldValue = it } ) Spacer(modifier = Modifier.height(8.dp)) Button(onClick = rendering.onSwapText) { diff --git a/workflow-ui/compose/api/compose.api b/workflow-ui/compose/api/compose.api index 5b4aef0d07..1157eee577 100644 --- a/workflow-ui/compose/api/compose.api +++ b/workflow-ui/compose/api/compose.api @@ -68,6 +68,10 @@ public final class com/squareup/workflow1/ui/compose/TextControllerAsMutableStat public static final fun asMutableState (Lcom/squareup/workflow1/ui/TextController;Landroidx/compose/runtime/Composer;I)Landroidx/compose/runtime/MutableState; } +public final class com/squareup/workflow1/ui/compose/TextControllerAsMutableTextFieldValueStateKt { + public static final fun asMutableTextFieldValueState (Lcom/squareup/workflow1/ui/TextController;IILandroidx/compose/runtime/Composer;II)Landroidx/compose/runtime/MutableState; +} + public final class com/squareup/workflow1/ui/compose/ViewEnvironmentWithComposeSupportKt { public static final fun RootScreen (Lcom/squareup/workflow1/ui/ViewEnvironment;Lcom/squareup/workflow1/ui/Screen;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V public static final fun withComposeInteropSupport (Lcom/squareup/workflow1/ui/ViewEnvironment;Lkotlin/jvm/functions/Function3;)Lcom/squareup/workflow1/ui/ViewEnvironment; diff --git a/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/TextControllerAsMutableTextFieldValueState.kt b/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/TextControllerAsMutableTextFieldValueState.kt new file mode 100644 index 0000000000..d1aad37e81 --- /dev/null +++ b/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/TextControllerAsMutableTextFieldValueState.kt @@ -0,0 +1,78 @@ +package com.squareup.workflow1.ui.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import com.squareup.workflow1.ui.TextController +import kotlinx.coroutines.launch + +/** + * A wrapper extension for [com.squareup.workflow1.ui.compose.asMutableState] that returns + * [TextFieldValue]. This makes it easy to use it with `MarketTextField` since `MarketTextField` + * expects [TextFieldValue]. + * + * @param selectionStart The starting index of the selection. + * @param selectionEnd The ending index of the selection. + * + * If [selectionStart] equals [selectionEnd] then nothing is selected, and the cursor is placed at + * [selectionStart]. By default, the cursor will be placed at the end of the text. + * + * Usage: + * + * var fooText by fooTextController.asMutableTextFieldValueState() + * BasicTextField( + * value = fooText, + * onValueChange = { fooText = it }, + * ) + * + */ +@Composable +public fun TextController.asMutableTextFieldValueState( + selectionStart: Int = textValue.length, + selectionEnd: Int = selectionStart, +): MutableState { + val textFieldValue = remember(this) { + val actualStart = selectionStart.coerceIn(0, textValue.length) + val actualEnd = selectionEnd.coerceIn(actualStart, textValue.length) + mutableStateOf( + TextFieldValue( + text = textValue, + // We need to set the selection manually when creating new `TextFieldValue` whenever + // `TextController` changes because the text inside may not be empty. + selection = TextRange(actualStart, actualEnd), + ) + ) + } + + LaunchedEffect(this) { + launch { + // This is to address the case when value of `TextController` is updated within the workflow. + // By subscribing directly to `onTextChanged` we can use this to also update the textFieldValue. + onTextChanged + .collect { newText -> + // Only update the `textFieldValue` if the new text is different from the current text. + // This ensures the selection is maintained when the text is updated from the UI side, + // and is only reset when the text is changed via `TextController`. + if (textFieldValue.value.text != newText) { + textFieldValue.value = TextFieldValue( + text = newText, + selection = TextRange(newText.length), + ) + } + } + } + + // Update this `TextController`'s text whenever the `textFieldValue` changes. + snapshotFlow { textFieldValue.value } + .collect { newText -> + textValue = newText.text + } + } + + return textFieldValue +} diff --git a/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/TextController.kt b/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/TextController.kt index 5f90741cd8..a998d7906c 100644 --- a/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/TextController.kt +++ b/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/TextController.kt @@ -26,7 +26,7 @@ import kotlinx.coroutines.flow.drop * function for your UI platform, e.g.: * * - `control()` for an Android EditText view - * - `asMutableState()` from an Android `@Composable` function + * - `asMutableState()` or `asMutableTextFieldValueState()` in an Android `@Composable` function * * If your workflow needs to access or change the current text value, get the value from [textValue]. * If your workflow needs to react to changes, it can observe [onTextChanged] by converting it to a