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..189dfcecfa 100644 --- a/workflow-ui/compose/api/compose.api +++ b/workflow-ui/compose/api/compose.api @@ -66,6 +66,7 @@ public final class com/squareup/workflow1/ui/compose/ScreenComposableFactoryKt { public final class com/squareup/workflow1/ui/compose/TextControllerAsMutableStateKt { public static final fun asMutableState (Lcom/squareup/workflow1/ui/TextController;Landroidx/compose/runtime/Composer;I)Landroidx/compose/runtime/MutableState; + public static final fun asMutableTextFieldValueState-Le-punE (Lcom/squareup/workflow1/ui/TextController;JLandroidx/compose/runtime/Composer;II)Landroidx/compose/runtime/MutableState; } public final class com/squareup/workflow1/ui/compose/ViewEnvironmentWithComposeSupportKt { diff --git a/workflow-ui/compose/src/androidTest/java/com/squareup/workflow1/ui/compose/TextControllerAsMutableStateTest.kt b/workflow-ui/compose/src/androidTest/java/com/squareup/workflow1/ui/compose/TextControllerAsMutableStateTest.kt new file mode 100644 index 0000000000..52a9039fcb --- /dev/null +++ b/workflow-ui/compose/src/androidTest/java/com/squareup/workflow1/ui/compose/TextControllerAsMutableStateTest.kt @@ -0,0 +1,120 @@ +package com.squareup.workflow1.ui.compose + +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import com.squareup.workflow1.ui.TextController +import com.squareup.workflow1.ui.internal.test.IdleAfterTestRule +import com.squareup.workflow1.ui.internal.test.IdlingDispatcherRule +import leakcanary.DetectLeaksAfterTestSuccess +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +internal class TextControllerAsMutableStateTest { + + private val composeRule = createComposeRule() + + @get:Rule val rules: RuleChain = RuleChain.outerRule(DetectLeaksAfterTestSuccess()) + .around(IdleAfterTestRule) + .around(composeRule) + .around(IdlingDispatcherRule) + + @Test fun setTextInCompose() { + val textController = TextController() + composeRule.setContent { + var state by textController.asMutableTextFieldValueState() + LaunchedEffect(Unit) { + state = TextFieldValue(text = "foo") + } + } + composeRule.runOnIdle { + assertThat(textController.textValue).isEqualTo("foo") + } + } + + @Test fun setTextInComposeWithSelection() { + val textController = TextController() + val textFieldValue = mutableStateOf(null) + composeRule.setContent { + var state by textController.asMutableTextFieldValueState() + LaunchedEffect(Unit) { + state = TextFieldValue(text = "foobar", selection = TextRange(1, 3)) + } + LaunchedEffect(Unit) { + snapshotFlow { state } + .collect { + textFieldValue.value = it + } + } + } + composeRule.runOnIdle { + assertThat(textController.textValue).isEqualTo("foobar") + assertThat(textFieldValue.value).isEqualTo( + TextFieldValue( + text = "foobar", + selection = TextRange(1, 3) + ) + ) + } + } + + @Test fun setTextViaTextController() { + val textController = TextController() + val textFieldValue = mutableStateOf(null) + composeRule.setContent { + val state by textController.asMutableTextFieldValueState() + LaunchedEffect(Unit) { + snapshotFlow { state } + .collect { + textFieldValue.value = it + } + } + } + textController.textValue = "foo" + composeRule.runOnIdle { + assertThat(textFieldValue.value).isEqualTo( + TextFieldValue( + text = "foo", + selection = TextRange(3) + ) + ) + } + } + + @Test fun withInitialSelectionSet() { + val textController = TextController("foobar") + val textFieldValue = mutableStateOf(null) + composeRule.setContent { + val state by textController.asMutableTextFieldValueState( + initialSelection = TextRange( + start = 1, + end = 3, + ), + ) + LaunchedEffect(Unit) { + snapshotFlow { state } + .collect { + textFieldValue.value = it + } + } + } + composeRule.runOnIdle { + assertThat(textFieldValue.value).isEqualTo( + TextFieldValue( + text = "foobar", + selection = TextRange(1, 3) + ) + ) + } + } +} diff --git a/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/TextControllerAsMutableState.kt b/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/TextControllerAsMutableState.kt index ee86b76865..776efd92f5 100644 --- a/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/TextControllerAsMutableState.kt +++ b/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/TextControllerAsMutableState.kt @@ -6,6 +6,8 @@ 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 @@ -25,7 +27,15 @@ import kotlinx.coroutines.launch * onValueChange = { text = it } * ) */ -@Composable public fun TextController.asMutableState(): MutableState { +@Deprecated( + message = "Deprecated in favor of asMutableTextFieldValueState()", + replaceWith = ReplaceWith( + expression = "asMutableTextFieldValueState()", + imports = arrayOf("com.squareup.workflow1.ui.compose.asMutableTextFieldValueState()"), + ) +) +@Composable +public fun TextController.asMutableState(): MutableState { // keys are set to `this` to reset the state if a different controller is passed in… return remember(this) { mutableStateOf(textValue) }.also { state -> // …and to restart the effect. @@ -42,3 +52,66 @@ import kotlinx.coroutines.launch } } } + +/** + * Exposes the [textValue][TextController.textValue] of a [TextController] + * as a remembered [MutableState] of [TextFieldValue], suitable for use from `@Composable` + * functions. + * + * Usage: + * + * ``` + * var fooText by fooTextController.asMutableTextFieldValueState() + * BasicTextField( + * value = fooText, + * onValueChange = { fooText = it }, + * ) + * ``` + * + * @param initialSelection The initial range of selection. If [TextRange.start] equals + * [TextRange.end], then nothing is selected, and the cursor is placed at + * [TextRange.start]. By default, the cursor will be placed at the end of the text. + */ +@Composable public fun TextController.asMutableTextFieldValueState( + initialSelection: TextRange = TextRange(textValue.length), +): MutableState { + val textFieldValue = remember(this) { + val actualStart = initialSelection.start.coerceIn(0, textValue.length) + val actualEnd = initialSelection.end.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 +}