0

I have an issue with view model, when I do a network call/viewModel validation, and then the re composition happens (for example Textfield onValueChange) the viewModel holds the reference to the last "state" from the viewModel is any way to "observe" the state only once? similar to SingleLiveEvent or should I "clear" the state from my viewModel somehow?

here is an Example

@Composable
fun TestViewModelStateCompose(viewModel: TestViewModel) {
    Column(modifier = Modifier.fillMaxSize()) {
        var email by remember { mutableStateOf("") }
        var error by remember { mutableStateOf<String?>(null) }

        var lceState = viewModel.lceState.observeAsState().value

        when (lceState) {
            is Lce.Content -> {
                // do something
                if(lceState.result.not()){
                    error = "Wrong email :/"
                }
            }
            is Lce.Error -> {
                error = "Wrong email"
            }
            Lce.Loading -> {
                //loading
            }
            null -> {}
        }

        TextField(
            value = email, onValueChange = {
                email = it
                error = null
            },
            modifier = Modifier.fillMaxWidth()
        )
        error?.let {
            Text(
                text = it,
                style = MaterialTheme.typography.labelMedium,
                color = MaterialTheme.colorScheme.error,
                modifier = Modifier
                    .padding(horizontal = 16.dp)
                    .padding(top = 8.dp)
                    .fillMaxWidth(),
                maxLines = 1
            )
        }
        Spacer(modifier = Modifier.weight(1f))
        Button(
            onClick = {
                viewModel.validateEmail()
            },
            modifier = Modifier
                .fillMaxWidth()
                .padding(bottom = 32.dp)
        ) {
            Text(
                text = "Validate email",
            )
        }
    }
}

sealed class Lce {
    object Loading : Lce()
    data class Content(val result: Boolean) : Lce()
    data class Error(val error: Throwable) : Lce()
}

class TestViewModel() : ViewModel() {
    val lceState = SingleLiveEvent<Lce>()
    private val isValid = false

    fun validateEmail() {
        if (isValid) {
            // do something
        } else {
            viewModelScope.launch {
                //place holder, the email is always invalid for test
                flowOf(false)
                    .onStart {
                        // simulate call
                        delay(1000)
                        lceState.postValue(Lce.Loading)
                    }
                    .catch { lceState.postValue(Lce.Error(it)) }
                    .collect {
                        lceState.postValue(Lce.Content(it))
                    }
            }
        }
    }
}

My main issue is when i call "validate emaial" and then I'm listening the var lceState = viewModel.lceState.observeAsState().value this works as expected, then I want to "clear" the error just setting to null, however this is being overriden by viewModel.lceState.observeAsState() because in every re composition it holds the last value. is possible to "observe" as only one time? or "autoClear" after the compose comsume this events?

Thanks a lot

Javier
  • 1,469
  • 2
  • 20
  • 38
  • Your approach tells me that you are trying to implement statemachine `Lce` indicating all states are mutually exclusive. What you should do is treat `Error` as a side effect. Maybe introduce different independent LiveData for just errors. And clear the error on dismiss of dialog or some user interaction or auto clear after laucnhing the SideEffect. Compose has SideEffect scope for this kind of use case. – AagitoEx Jul 25 '22 at 11:50

2 Answers2

2

I recommend using MVI and using Event to represent the ViewModel's one-time data for compose. For example:

interface Event

abstract class BaseViewModel<E : Event> : ViewModel() {
    private val _event = Channel<E>()
    val event = _event.receiveAsFlow().shareIn(viewModelScope, SharingStarted.Lazily)
    protected suspend fun sendEvent(event: E) = _event.send(event)
    protected fun sendEventSync(event: E) = viewModelScope.launch { _event.send(event) }
}

@Composable
fun <E : Event> OnEvent(event: Flow<E>, onEvent: (E) -> Unit) {
    LaunchedEffect(Unit) {
        event.collect(onEvent)
    }
}

Then you can do this:


@Composable
fun TestViewModelStateCompose(viewModel: TestViewModel) {
    Column(modifier = Modifier.fillMaxSize()) {
        var email by remember { mutableStateOf("") }
        var error by remember { mutableStateOf<String?>(null) }

        OnEvent(viewModel.event) {
            when (it) {
                is TestEvent.ValidateEmailFailure -> error = "Wrong email :/"
                is TestEvent.ValidateEmailLoading -> Unit // loading
                TestEvent.ValidateEmailSuccess -> Unit // do something like toast
            }
        }

        TextField(
            value = email, onValueChange = {
                email = it
                error = null
            },
            modifier = Modifier.fillMaxWidth()
        )
        error?.let {
            Text(
                text = it,
                style = MaterialTheme.typography.labelMedium,
                color = MaterialTheme.colorScheme.error,
                modifier = Modifier
                    .padding(horizontal = 16.dp)
                    .padding(top = 8.dp)
                    .fillMaxWidth(),
                maxLines = 1
            )
        }
        Spacer(modifier = Modifier.weight(1f))
        Button(
            onClick = {
                viewModel.validateEmail()
            },
            modifier = Modifier
                .fillMaxWidth()
                .padding(bottom = 32.dp)
        ) {
            Text(
                text = "Validate email",
            )
        }
    }
}

sealed interface TestEvent : Event {
    object ValidateEmailLoading : TestEvent
    object ValidateEmailSuccess : TestEvent
    data class ValidateEmailFailure(val exception: Throwable) : TestEvent
}

class TestViewModel : BaseViewModel<TestEvent>() {
    private val isValid = false

    private suspend fun simulateValidate(): Result<Unit> {
        return runCatching {
            delay(1000)
        }
    }

    fun validateEmail() {
        if (isValid) {
            // do something
        } else {
            viewModelScope.launch {
                sendEvent(TestEvent.ValidateEmailLoading)
                simulateValidate()
                    .onSuccess { sendEvent(TestEvent.ValidateEmailSuccess) }
                    .onFailure { sendEvent(TestEvent.ValidateEmailFailure(it)) }
            }
        }
    }
}

It is better to handle Events and Actions at the very beginning of the Screen to avoid taking the ViewModel as a parameter of compose function. This helps with testing and code reuse.

FishHawk
  • 414
  • 4
  • 11
0

One option for doing would be adding an Idle state and changing lceState to this state to simulate nothing is happening. I use this approach when i don't want to show an initial loading Composable

Thracian
  • 43,021
  • 16
  • 133
  • 222
  • So let's say after the "Error" happened you send an event like `viewModel.clearStatus()` and set the `lceState` as null or "Idle" status? – Javier Jul 25 '22 at 11:45
  • You can return to Idle state after setting `error = null`. I would pick as State.Idle because null doesn't represent a state. You can use any state your app can be in and define them inside your sealed class. That's how i do it. Of course this is open to debate you can pick any strategies that suits your logic and app well – Thracian Jul 25 '22 at 11:50
  • I use this with Canvas mostly. When user removes pointer if i leave it on Up state when recomposition happens it draws a line but after processing Up event i set it to Idle so on next recomposition nothing happens related to Canvas. https://stackoverflow.com/a/71090112/5457853 Up is State that should be processed only once – Thracian Jul 25 '22 at 11:53