Home > Software design >  Problem with LaunchedEffect in composables of HorizontalPager
Problem with LaunchedEffect in composables of HorizontalPager

Time:09-21

I'm creating a project with Compose, but I ran into a situation that I couldn't solve.

View Model:

data class OneState(
    val name: String = "",
    val city: String = ""
)

sealed class OneChannel {
    object FirstStepToSecondStep : OneChannel()
    object Finish : OneChannel()
}

@HiltViewModel
class OneViewModel @Inject constructor() : ViewModel() {
    private val viewModelState = MutableStateFlow(OneState())
    val screenState = viewModelState.stateIn(
        scope = viewModelScope,
        started = SharingStarted.Eagerly,
        initialValue = viewModelState.value
    )

    private val _channel = Channel<OneChannel>()
    val channel = _channel.receiveAsFlow()

    fun changeName(value: String) {
        viewModelState.update { it.copy(name = value) }
    }

    fun changeCity(value: String) {
        viewModelState.update { it.copy(city = value) }
    }

    fun firstStepToSecondStep() {
        Log.d("OneViewModel", "start of method first step to second step")

        if (viewModelState.value.name.isBlank()) {
            Log.d("OneViewModel", "name is empty, nothing should be done")
            return
        }

        Log.d(
            "OneViewModel",
            "name is not empty, first step to second step event will be send for composable"
        )
        viewModelScope.launch {
            _channel.send(OneChannel.FirstStepToSecondStep)
        }
    }

    fun finish() {
        Log.d("OneViewModel", "start of method finish")

        if (viewModelState.value.city.isBlank()) {
            Log.d("OneViewModel", "city is empty, nothing should be done")
            return
        }

        Log.d(
            "OneViewModel",
            "city is not empty, finish event will be send for composable"
        )
        viewModelScope.launch {
            _channel.send(OneChannel.Finish)
        }
    }
}

This ViewModel has a MutableStateFlow, a StateFlow to be collected on composable screens and a Channel/Flow for "one time events".
The first two methods are to change a respective state and the last two methods are to validate some logic and then send an event through the Channel.

Composables:

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FirstStep(
    viewModel: OneViewModel,
    nextStep: () -> Unit
) {
    val state by viewModel.screenState.collectAsState()

    LaunchedEffect(key1 = Unit) {
        Log.d("FirstStep (Composable)", "start of launched effect block")

        viewModel.channel.collect { channel ->
            when (channel) {
                OneChannel.FirstStepToSecondStep -> {
                    Log.d("FirstStep (Composable)", "first step to second step action")
                    nextStep()
                }
                else -> Log.d(
                    "FirstStep (Composable)",
                    "another action that should be ignored in this scope"
                )
            }
        }
    }

    Column(modifier = Modifier.fillMaxSize()) {
        TextField(
            modifier = Modifier
                .fillMaxWidth()
                .padding(all = 16.dp),
            value = state.name,
            onValueChange = { viewModel.changeName(value = it) },
            placeholder = { Text(text = "Type our name") }
        )

        Spacer(modifier = Modifier.weight(weight = 1F))

        Button(
            modifier = Modifier
                .fillMaxWidth()
                .padding(horizontal = 16.dp),
            onClick = { viewModel.firstStepToSecondStep() }
        ) {
            Text(text = "Next Step")
        }
    }
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SecondStep(
    viewModel: OneViewModel,
    prevStep: () -> Unit,
    finish: () -> Unit
) {
    val state by viewModel.screenState.collectAsState()

    LaunchedEffect(key1 = Unit) {
        Log.d("SecondStep (Composable)", "start of launched effect block")

        viewModel.channel.collect { channel ->
            when (channel) {
                OneChannel.Finish -> {
                    Log.d("SecondStep (Composable)", "finish action //todo")
                    finish()
                }
                else -> Log.d(
                    "SecondStep (Composable)",
                    "another action that should be ignored in this scope"
                )
            }
        }
    }

    Column(modifier = Modifier.fillMaxSize()) {
        TextField(
            modifier = Modifier
                .fillMaxWidth()
                .padding(all = 16.dp),
            value = state.city,
            onValueChange = { viewModel.changeCity(value = it) },
            placeholder = { Text(text = "Type our city name") }
        )

        Spacer(modifier = Modifier.weight(weight = 1F))

        Row(
            modifier = Modifier
                .fillMaxWidth()
                .padding(horizontal = 16.dp),
            horizontalArrangement = Arrangement.spacedBy(space = 16.dp)
        ) {
            Button(
                modifier = Modifier.weight(weight = 1F),
                onClick = prevStep
            ) {
                Text(text = "Previous Step")
            }

            Button(
                modifier = Modifier.weight(weight = 1F),
                onClick = { viewModel.finish() }
            ) {
                Text(text = "Finish")
            }
        }
    }
}
@OptIn(ExperimentalPagerApi::class)
@Composable
fun OneScreen(viewModel: OneViewModel = hiltViewModel()) {
    val coroutineScope = rememberCoroutineScope()

    val pagerState = rememberPagerState(initialPage = 0)
    val pages = listOf<@Composable () -> Unit>(
        {
            FirstStep(
                viewModel = viewModel,
                nextStep = {
                    coroutineScope.launch {
                        pagerState.animateScrollToPage(page = pagerState.currentPage   1)
                    }
                }
            )
        },
        {
            SecondStep(
                viewModel = viewModel,
                prevStep = {
                    coroutineScope.launch {
                        pagerState.animateScrollToPage(page = pagerState.currentPage - 1)
                    }
                },
                finish = {}
            )
        }
    )

    Column(modifier = Modifier.fillMaxSize()) {
        HorizontalPager(
            modifier = Modifier
                .fillMaxWidth()
                .weight(weight = 1F),
            state = pagerState,
            count = pages.size,
            userScrollEnabled = false
        ) { index ->
            pages[index]()
        }

        HorizontalPagerIndicator(
            modifier = Modifier
                .padding(vertical = 16.dp)
                .align(alignment = Alignment.CenterHorizontally),
            pagerState = pagerState,
            activeColor = MaterialTheme.colorScheme.primary
        )
    }
}

OneScreen has a HorizontalPager (from the Accompanist library) which receives two other composables, FirstStep and SecondStep, these two composables have their own LaunchedEffect to collect any possible event coming from the View Model.

Dependencies used:

implementation 'androidx.navigation:navigation-compose:2.5.2'

implementation 'com.google.dagger:hilt-android:2.43.2'
kapt 'com.google.dagger:hilt-android-compiler:2.43.2'

implementation 'androidx.hilt:hilt-navigation-compose:1.0.0'

implementation 'com.google.accompanist:accompanist-pager:0.25.1'
implementation 'com.google.accompanist:accompanist-pager-indicators:0.25.1'

The problem:
After typing something in the name field and clicking to go to the next step, the flow happens normally. When clicking to go back to the previous step, it also works normally. But now when clicking to go to the next step again, the collect in the LaunchedEffect of the FirstStep is not collected, instead the collect in LaunchedEffect of the SecondStep is, resulting in no action, and if click again, then collect in FirstStep works.

Some images that follow the logcat:

  1. enter image description here

  • Related