Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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> { rendering ->
Expand All @@ -30,14 +30,14 @@ val TextInputComposableFactory = ScreenComposableFactory<Rendering> { 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) {
Expand Down
1 change: 1 addition & 0 deletions workflow-ui/compose/api/compose.api
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<TextFieldValue?>(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<TextFieldValue?>(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<TextFieldValue?>(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)
)
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -25,7 +27,15 @@ import kotlinx.coroutines.launch
* onValueChange = { text = it }
* )
*/
@Composable public fun TextController.asMutableState(): MutableState<String> {
@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<String> {
// 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.
Expand All @@ -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<TextFieldValue> {
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
}
Loading