Skip to content

Commit bb7541c

Browse files
authored
Merge pull request #1299 from darshanparajuli/darshan/textcontroller-textfieldvalue-extension
Add an extension on `TextController` that returns a mutable state of `TextFieldValue`
2 parents 030bc23 + 49141b4 commit bb7541c

File tree

4 files changed

+200
-6
lines changed

4 files changed

+200
-6
lines changed

samples/compose-samples/src/main/java/com/squareup/sample/compose/textinput/TextInputViewFactory.kt

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import androidx.compose.ui.unit.dp
1919
import com.squareup.sample.compose.textinput.TextInputWorkflow.Rendering
2020
import com.squareup.workflow1.ui.TextController
2121
import com.squareup.workflow1.ui.compose.ScreenComposableFactory
22-
import com.squareup.workflow1.ui.compose.asMutableState
22+
import com.squareup.workflow1.ui.compose.asMutableTextFieldValueState
2323
import com.squareup.workflow1.ui.compose.tooling.Preview
2424

2525
val TextInputComposableFactory = ScreenComposableFactory<Rendering> { rendering ->
@@ -30,14 +30,14 @@ val TextInputComposableFactory = ScreenComposableFactory<Rendering> { rendering
3030
.animateContentSize(),
3131
horizontalAlignment = Alignment.CenterHorizontally
3232
) {
33-
var text by rendering.textController.asMutableState()
33+
var textFieldValue by rendering.textController.asMutableTextFieldValueState()
3434

35-
Text(text = text)
35+
Text(text = textFieldValue.text)
3636
OutlinedTextField(
3737
label = {},
3838
placeholder = { Text("Enter some text") },
39-
value = text,
40-
onValueChange = { text = it }
39+
value = textFieldValue,
40+
onValueChange = { textFieldValue = it }
4141
)
4242
Spacer(modifier = Modifier.height(8.dp))
4343
Button(onClick = rendering.onSwapText) {

workflow-ui/compose/api/compose.api

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ public final class com/squareup/workflow1/ui/compose/ScreenComposableFactoryKt {
6666

6767
public final class com/squareup/workflow1/ui/compose/TextControllerAsMutableStateKt {
6868
public static final fun asMutableState (Lcom/squareup/workflow1/ui/TextController;Landroidx/compose/runtime/Composer;I)Landroidx/compose/runtime/MutableState;
69+
public static final fun asMutableTextFieldValueState-Le-punE (Lcom/squareup/workflow1/ui/TextController;JLandroidx/compose/runtime/Composer;II)Landroidx/compose/runtime/MutableState;
6970
}
7071

7172
public final class com/squareup/workflow1/ui/compose/ViewEnvironmentWithComposeSupportKt {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
package com.squareup.workflow1.ui.compose
2+
3+
import androidx.compose.runtime.LaunchedEffect
4+
import androidx.compose.runtime.getValue
5+
import androidx.compose.runtime.mutableStateOf
6+
import androidx.compose.runtime.setValue
7+
import androidx.compose.runtime.snapshotFlow
8+
import androidx.compose.ui.test.junit4.createComposeRule
9+
import androidx.compose.ui.text.TextRange
10+
import androidx.compose.ui.text.input.TextFieldValue
11+
import androidx.test.ext.junit.runners.AndroidJUnit4
12+
import com.google.common.truth.Truth.assertThat
13+
import com.squareup.workflow1.ui.TextController
14+
import com.squareup.workflow1.ui.internal.test.IdleAfterTestRule
15+
import com.squareup.workflow1.ui.internal.test.IdlingDispatcherRule
16+
import leakcanary.DetectLeaksAfterTestSuccess
17+
import org.junit.Rule
18+
import org.junit.Test
19+
import org.junit.rules.RuleChain
20+
import org.junit.runner.RunWith
21+
22+
@RunWith(AndroidJUnit4::class)
23+
internal class TextControllerAsMutableStateTest {
24+
25+
private val composeRule = createComposeRule()
26+
27+
@get:Rule val rules: RuleChain = RuleChain.outerRule(DetectLeaksAfterTestSuccess())
28+
.around(IdleAfterTestRule)
29+
.around(composeRule)
30+
.around(IdlingDispatcherRule)
31+
32+
@Test fun setTextInCompose() {
33+
val textController = TextController()
34+
composeRule.setContent {
35+
var state by textController.asMutableTextFieldValueState()
36+
LaunchedEffect(Unit) {
37+
state = TextFieldValue(text = "foo")
38+
}
39+
}
40+
composeRule.runOnIdle {
41+
assertThat(textController.textValue).isEqualTo("foo")
42+
}
43+
}
44+
45+
@Test fun setTextInComposeWithSelection() {
46+
val textController = TextController()
47+
val textFieldValue = mutableStateOf<TextFieldValue?>(null)
48+
composeRule.setContent {
49+
var state by textController.asMutableTextFieldValueState()
50+
LaunchedEffect(Unit) {
51+
state = TextFieldValue(text = "foobar", selection = TextRange(1, 3))
52+
}
53+
LaunchedEffect(Unit) {
54+
snapshotFlow { state }
55+
.collect {
56+
textFieldValue.value = it
57+
}
58+
}
59+
}
60+
composeRule.runOnIdle {
61+
assertThat(textController.textValue).isEqualTo("foobar")
62+
assertThat(textFieldValue.value).isEqualTo(
63+
TextFieldValue(
64+
text = "foobar",
65+
selection = TextRange(1, 3)
66+
)
67+
)
68+
}
69+
}
70+
71+
@Test fun setTextViaTextController() {
72+
val textController = TextController()
73+
val textFieldValue = mutableStateOf<TextFieldValue?>(null)
74+
composeRule.setContent {
75+
val state by textController.asMutableTextFieldValueState()
76+
LaunchedEffect(Unit) {
77+
snapshotFlow { state }
78+
.collect {
79+
textFieldValue.value = it
80+
}
81+
}
82+
}
83+
textController.textValue = "foo"
84+
composeRule.runOnIdle {
85+
assertThat(textFieldValue.value).isEqualTo(
86+
TextFieldValue(
87+
text = "foo",
88+
selection = TextRange(3)
89+
)
90+
)
91+
}
92+
}
93+
94+
@Test fun withInitialSelectionSet() {
95+
val textController = TextController("foobar")
96+
val textFieldValue = mutableStateOf<TextFieldValue?>(null)
97+
composeRule.setContent {
98+
val state by textController.asMutableTextFieldValueState(
99+
initialSelection = TextRange(
100+
start = 1,
101+
end = 3,
102+
),
103+
)
104+
LaunchedEffect(Unit) {
105+
snapshotFlow { state }
106+
.collect {
107+
textFieldValue.value = it
108+
}
109+
}
110+
}
111+
composeRule.runOnIdle {
112+
assertThat(textFieldValue.value).isEqualTo(
113+
TextFieldValue(
114+
text = "foobar",
115+
selection = TextRange(1, 3)
116+
)
117+
)
118+
}
119+
}
120+
}

workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/TextControllerAsMutableState.kt

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import androidx.compose.runtime.MutableState
66
import androidx.compose.runtime.mutableStateOf
77
import androidx.compose.runtime.remember
88
import androidx.compose.runtime.snapshotFlow
9+
import androidx.compose.ui.text.TextRange
10+
import androidx.compose.ui.text.input.TextFieldValue
911
import com.squareup.workflow1.ui.TextController
1012
import kotlinx.coroutines.launch
1113

@@ -25,7 +27,15 @@ import kotlinx.coroutines.launch
2527
* onValueChange = { text = it }
2628
* )
2729
*/
28-
@Composable public fun TextController.asMutableState(): MutableState<String> {
30+
@Deprecated(
31+
message = "Deprecated in favor of asMutableTextFieldValueState()",
32+
replaceWith = ReplaceWith(
33+
expression = "asMutableTextFieldValueState()",
34+
imports = arrayOf("com.squareup.workflow1.ui.compose.asMutableTextFieldValueState()"),
35+
)
36+
)
37+
@Composable
38+
public fun TextController.asMutableState(): MutableState<String> {
2939
// keys are set to `this` to reset the state if a different controller is passed in…
3040
return remember(this) { mutableStateOf(textValue) }.also { state ->
3141
// …and to restart the effect.
@@ -42,3 +52,66 @@ import kotlinx.coroutines.launch
4252
}
4353
}
4454
}
55+
56+
/**
57+
* Exposes the [textValue][TextController.textValue] of a [TextController]
58+
* as a remembered [MutableState] of [TextFieldValue], suitable for use from `@Composable`
59+
* functions.
60+
*
61+
* Usage:
62+
*
63+
* ```
64+
* var fooText by fooTextController.asMutableTextFieldValueState()
65+
* BasicTextField(
66+
* value = fooText,
67+
* onValueChange = { fooText = it },
68+
* )
69+
* ```
70+
*
71+
* @param initialSelection The initial range of selection. If [TextRange.start] equals
72+
* [TextRange.end], then nothing is selected, and the cursor is placed at
73+
* [TextRange.start]. By default, the cursor will be placed at the end of the text.
74+
*/
75+
@Composable public fun TextController.asMutableTextFieldValueState(
76+
initialSelection: TextRange = TextRange(textValue.length),
77+
): MutableState<TextFieldValue> {
78+
val textFieldValue = remember(this) {
79+
val actualStart = initialSelection.start.coerceIn(0, textValue.length)
80+
val actualEnd = initialSelection.end.coerceIn(actualStart, textValue.length)
81+
mutableStateOf(
82+
TextFieldValue(
83+
text = textValue,
84+
// We need to set the selection manually when creating new `TextFieldValue` whenever
85+
// `TextController` changes because the text inside may not be empty.
86+
selection = TextRange(actualStart, actualEnd),
87+
)
88+
)
89+
}
90+
91+
LaunchedEffect(this) {
92+
launch {
93+
// This is to address the case when value of `TextController` is updated within the workflow.
94+
// By subscribing directly to `onTextChanged` we can use this to also update the textFieldValue.
95+
onTextChanged
96+
.collect { newText ->
97+
// Only update the `textFieldValue` if the new text is different from the current text.
98+
// This ensures the selection is maintained when the text is updated from the UI side,
99+
// and is only reset when the text is changed via `TextController`.
100+
if (textFieldValue.value.text != newText) {
101+
textFieldValue.value = TextFieldValue(
102+
text = newText,
103+
selection = TextRange(newText.length),
104+
)
105+
}
106+
}
107+
}
108+
109+
// Update this `TextController`'s text whenever the `textFieldValue` changes.
110+
snapshotFlow { textFieldValue.value }
111+
.collect { newText ->
112+
textValue = newText.text
113+
}
114+
}
115+
116+
return textFieldValue
117+
}

0 commit comments

Comments
 (0)