androidkotlinandroid-jetpack-composejetpack-compose-accompanist

Problem with LaunchedEffect in composables of HorizontalPager


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. when opening the app
  2. after typing something and clicking to go to the next step
  3. going back to the first step
  4. clicking to go to next step (problem)
  5. clicking for the second time (works)

Solution

  • The problem is that HorizontalPager creates both the current page and the next page. When current page is FirstStep, both collectors are active and will be triggered sequentially.

    Let's look at the three jump attempts on the first page. The first attempt is received by collector in FirstStep and successfully jumps to the second page. The second attempt is received by collector in SecondStep and fails. The third attempt succeeds again.

    Actually, HorizontalPager is LazyRow, so this should be the result of LazyLayout's place logic.

    To solve this problem, I suggest merging the two LaunchedEffect and moving it into OneScreen. In fact, the viewmodel should all be moved to the top of the OneScreen, for cleaner code.

    At last, here is my simplified code if you want try it.

    @Composable
    fun Step(index: Int, flow: Flow<String>, onSwitch: () -> Unit, onSend: () -> Unit) {
        LaunchedEffect(Unit) {
            println("LaunchEffect$index")
            flow.collect { println("Step$index:$it") }
        }
        Column {
            Text(text = index.toString(), style = MaterialTheme.typography.h3)
            Button(onClick = onSwitch) { Text(text = "Switch Page") }
            Button(onClick = onSend) { Text(text = "Send") }
        }
    }
    
    @OptIn(ExperimentalPagerApi::class)
    @Composable
    fun Test() {
        val channel = remember { Channel<String>() }
        val flow = remember { channel.receiveAsFlow() }
        val scope = rememberCoroutineScope()
        val pagerState = rememberPagerState()
        HorizontalPager(
            modifier = Modifier.fillMaxSize(),
            state = pagerState,
            count = 4,
            userScrollEnabled = false,
        ) { index ->
            Step(index = index, flow = flow,
                onSwitch = {
                    scope.launch { pagerState.scrollToPage((index + 1) % pagerState.pageCount) }
                },
                onSend = {
                    scope.launch { channel.send("Test") }
                }
            )
        }
    }
    

    If you keep click send button at first page, it will print: enter image description here