Mateu
05/05/2023, 3:18 PMdata class AppState(val text: String, val number: Int){
companion object{
val Empty = AppState("", 0)
}
}
class AppViewModel(viewModelScope: CoroutineScope) {
private val randomNumber = flow{
while(true) {
emit(Random.nextInt())
delay(1000)
}
}
private val textField = MutableStateFlow("")
val state: StateFlow<AppState> = combine(
textField,
randomNumber,
::AppState,
).stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = AppState.Empty,
)
fun updateText(text: String){
textField.value = text
}
}
@Composable
fun App() {
val viewModelScope = rememberCoroutineScope()
val viewModel = remember{AppViewModel(viewModelScope)}
val viewState by viewModel.state.collectAsState(Dispatchers.Main.immediate)
Column {
TextField(viewState.text, onValueChange = viewModel::updateText)
Text(viewState.number.toString())
}
}
fun main() = application {
Window(onCloseRequest = ::exitApplication) {
App()
}
}
If you play the example and press two letters very (very) fast, the first one is omitted.
I think that the issue is described in the following post: https://medium.com/androiddevelopers/effective-state-management-for-textfield-in-compose-d6e5b070fbe5
For short, there is a race condition and before the TextField is modified after the combine, the second letter is pressed, so the onchange does not contain the first letter.
I've tried to look in many examples moving in internet, but most of them do not contain textFields. The only example that I've found that can apply is Tivi from @cb . In this line https://github.com/chrisbanes/tivi/blob/dffc1e9036cad936bc78236ce9e017bed21e7e7b/ui/search/src/main/java/app/tivi/home/search/Search.kt#L142
he uses a secondary state to store the textfield Input, and the textfield value is not updated with the main state. This is the only solution? We need to create a separate state for the textfields?
As you can see I'm allready using collectAsState(Dispatchers.Main.immediate) that is one of the solutions that I've found in internet, but does not seem to help at all.
Thanks!Zach Klippenstein (he/him) [MOD]
05/05/2023, 6:10 PMColton Idle
05/05/2023, 11:14 PMMateu
05/08/2023, 9:21 AM/**
* Returns current time milis every second
*/
fun currentTime() = flow{
while(currentCoroutineContext().isActive) {
emit(System.currentTimeMillis())
delay(1000)
}
}
/**
* Simulates obtaining user profile from BD
*/
fun getBdUserProfile() = flow{
delay(2000) //simulate slow DB access
emit(UserProfile("John Doe", "<mailto:john@example.com|john@example.com>"))
}
/**
* Simulates cheking if email is allready present in DB
*/
fun alreadyUsedEmail(email :String) = flow{
delay(300) //simulate slow DB access
emit(email.startsWith("a"))
}
fun saveUser(userProfile: UserProfile){
println("Saving: $userProfile")
}
val notDigits = "\\D*".toRegex()
sealed interface EditUserProfileState{
object LoadingState : EditUserProfileState
data class UserProfileFormState(val userProfile: UserProfile, val currentMilis: Long, val invalidNameError : Boolean = false, val alreadyUsedEmail: Boolean = false):EditUserProfileState
}
data class UserProfile(val name: String ="", val email: String ="")
class EditUserProfileViewModel(viewModelScope: CoroutineScope) {
private val currentTime = currentTime()
private val storedUserProfileFlow = getBdUserProfile()
private val userProfile = MutableStateFlow<UserProfile?>(null)
private val alreadyUsedEmail = userProfile.debounce(500).flatMapLatest {
if(it==null) flow{true}
else alreadyUsedEmail(it.email)
}
init{
viewModelScope.launch {
val storedUserProfile = storedUserProfileFlow.first()
immediateState.value = storedUserProfile
userProfile.value = storedUserProfile
}
}
val immediateState = mutableStateOf(UserProfile())
val state: StateFlow<EditUserProfileState> = combine(
userProfile,
currentTime,
alreadyUsedEmail
){ userProfile, currentTime, alreadyUsedEmail ->
if(userProfile==null)
EditUserProfileState.LoadingState
else {
val invalidNameError = !notDigits.matches(userProfile.name)
EditUserProfileState.UserProfileFormState(userProfile, currentTime, invalidNameError, alreadyUsedEmail)
}
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = EditUserProfileState.LoadingState,
)
fun updateName(name: String){
immediateState.value = immediateState.value.copy(name = name)
userProfile.value = immediateState.value
}
fun updateEmail(email: String){
immediateState.value = immediateState.value.copy(email = email)
userProfile.value = immediateState.value
}
fun save(){
saveUser(immediateState.value)
}
}
@Composable
@Preview
fun App() {
val viewModelScope = rememberCoroutineScope()
val viewModel = remember{EditUserProfileViewModel(viewModelScope)}
val viewState by viewModel.state.collectAsState(Dispatchers.Main.immediate)
val userProfileForm by viewModel.immediateState
val currentUserProfileForm = userProfileForm
when(val currentViewState = viewState){
EditUserProfileState.LoadingState -> Text("Loading")
is EditUserProfileState.UserProfileFormState -> {
Column {
if(currentViewState.invalidNameError)
Text("Name can't contain numbers")
TextField(currentUserProfileForm.name, onValueChange = viewModel::updateName)
if(currentViewState.alreadyUsedEmail)
Text("Already used email")
TextField(currentUserProfileForm.email, onValueChange = viewModel::updateEmail)
Text(currentViewState.currentMilis.toString())
Button(onClick = viewModel::save) {
Text("Save")
}
}
}
}
}
fun main() = application {
Window(onCloseRequest = ::exitApplication) {
App()
}
}
marcu
06/07/2023, 7:19 AM@Composable
fun TextFieldStateful(
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
readOnly: Boolean = false,
textStyle: TextStyle = LocalTextStyle.current,
label: @Composable (() -> Unit)? = null,
placeholder: @Composable (() -> Unit)? = null,
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
isError: Boolean = false,
visualTransformation: VisualTransformation = VisualTransformation.None,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActions = KeyboardActions(),
singleLine: Boolean = false,
maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE,
minLines: Int = 1,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
shape: Shape =
MaterialTheme.shapes.small.copy(bottomEnd = ZeroCornerSize, bottomStart = ZeroCornerSize),
colors: TextFieldColors = TextFieldDefaults.textFieldColors()
) {
var state by remember(value) { mutableStateOf(value) }
snapshotFlow { state }
.mapLatest {
onValueChange(it)
}
.stateIn(
scope = rememberCoroutineScope(),
started = SharingStarted.Lazily,
initialValue = state
).collectAsState()
TextField(
value = state,
onValueChange = { newValue ->
state = newValue
},
modifier = modifier,
enabled = enabled,
readOnly = readOnly,
textStyle = textStyle,
label = label,
placeholder = placeholder,
leadingIcon = leadingIcon,
trailingIcon = trailingIcon,
isError = isError,
visualTransformation = visualTransformation,
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
singleLine = singleLine,
maxLines = maxLines,
minLines = minLines,
interactionSource = interactionSource,
shape = shape,
colors = colors
)
}
Zach Klippenstein (he/him) [MOD]
06/07/2023, 5:24 PMYou shouldn’t override the dispatcher this sort of thing. Compose’s coroutine scope (bothCopy codecollectAsState(Dispatchers.Main.immediate)
rememberCoroutineScope
and LaunchedEffect
) run on the compose dispatcher, which always runs on the main thread and additionally will ensure all continuations are drained before starting recomposition.