androidandroid-jetpack-composekotlin-flowandroid-jetpack-datastore

Jetpack Compose - DataStore/Flows problem


I can't understand why content of collect function in SplashViewModel is executed after "Finish" button click.

Of course content is called when app starts, because SplashViewModel is injected in the MainActivity and the 'init' function from 'SplashViewModel' is called but i don't know why app is moving me to AUTH graph after clicking Finish button...

This is the part of code which i mean when im using a 'content' word:

val route =
                        if (completed == true) NavigationDestinations.Auth.GRAPH else NavigationDestinations.OnBoarding.GRAPH
                    splashEventChannel.send(BaseUiEvent.Navigate(route))

                    _isLoading.value = false

It looks like when 'saveOnBoardingState' function from 'OnBoardingViewModel' is executed then is re-emitted last flow value in SplashViewModel or something?

Here is my code:

'DataStoreManager' File

val Context.dataStore: DataStore<Preferences> by preferencesDataStore("user_preferences")

class DataStoreManager(context: Context) {

    private val dataStore = context.dataStore

    fun <T> getValueFromDataStore(preferencesKey: Preferences.Key<T>) =
        dataStore.data.catch {
            if (it is IOException) {
                emit(emptyPreferences())
            } else {
                throw it
            }
        }.map {
            val value = it[preferencesKey]
            value
        }


    suspend fun <T> storeValue(key: Preferences.Key<T>, value: T) =
        dataStore.edit { it[key] = value }

}
class SplashViewModel @Inject constructor(private val dataStoreManager: DataStoreManager) :
    ViewModel() {
    private val _isLoading = MutableStateFlow(true)
    val isLoading = _isLoading.asStateFlow();

    private val splashEventChannel = Channel<UiEvent>()
    val splashScreenEvents = splashEventChannel.receiveAsFlow()

    init {
        viewModelScope.launch(Dispatchers.IO) {
            dataStoreManager.getValueFromDataStore(PreferenceKey.onBoardingCompleted)
                .collect { completed ->
                    val route =
                        if (completed == true) NavigationDestinations.Auth.GRAPH else NavigationDestinations.OnBoarding.GRAPH
                    splashEventChannel.send(BaseUiEvent.Navigate(route))

                    _isLoading.value = false
                }
        }
    }
}
@ExperimentalPagerApi
@AndroidEntryPoint
class MainActivity : ComponentActivity() {

    @Inject
    lateinit var splashViewModel: SplashViewModel

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        installSplashScreen().apply {
            setKeepOnScreenCondition {
                splashViewModel.isLoading.value
            }
        }

        setContent {
            MyTheme {
                val navController = rememberNavController()
                val scaffoldState = rememberScaffoldState()

                LaunchedEffect(key1 = true) {
                    splashViewModel.splashScreenEvents.collect { event ->
                        when (event) {
                            is BaseUiEvent.Navigate -> {
//                                navController.popBackStack()
                                navController.navigate(event.route)
                            }
                        }
                    }
                }

                Scaffold(
                    scaffoldState = scaffoldState,
                    content = { innerPadding ->
                        Surface(
                            modifier = Modifier
                                .fillMaxSize()
                                .padding(innerPadding)
                        ) {
                            SetupNavGraph(
                                navController = navController,
                                scaffoldState = scaffoldState
                            )
                        }
                    }
                )
            }
        }
    }
}
@ExperimentalPagerApi
@Composable
fun OnBoardingScreen(
//    navController: NavHostController,
    onBoardingViewModel: OnBoardingViewModel = hiltViewModel()
) {
    val pages = listOf(
        OnBoardingPage.OnBoardingWelcomePage,
        OnBoardingPage.OnBoardingSecondPage,
        OnBoardingPage.OnBoardingLastPage
    )

    val pagerState = rememberPagerState()
    val scope = rememberCoroutineScope()

    Box(
        modifier = Modifier
            .fillMaxSize()
            .padding(vertical = 16.dp)
    ) {
        HorizontalPager(
            modifier = Modifier.fillMaxSize(),
            count = pages.count(),
            state = pagerState,
        ) { page ->
            Box(
                modifier = Modifier
                    .fillMaxSize()
                    .padding(horizontal = 16.dp)
            ) {
                OnBoardingScreenPage(
                    modifier = Modifier.padding(bottom = 200.dp),
                    onBoardingPage = pages[page]
                )
            }
        }

        Indicators(
            modifier = Modifier
                .align(Alignment.BottomCenter)
                .padding(bottom = 80.dp),
            size = pages.size,
            index = pagerState.currentPage
        )

        Box(
            modifier = Modifier
                .fillMaxWidth()
                .height(48.dp)
                .align(Alignment.BottomCenter)
        ) {
            AnimatedVisibility(
                visible = pagerState.currentPage != pagerState.pageCount - 1,
                enter = slideInHorizontally(initialOffsetX = { -it }).plus(fadeIn()),
                exit = slideOutHorizontally(targetOffsetX = { -it }).plus(fadeOut())
            ) {
                TextButton(
                    modifier = Modifier
                        .fillMaxHeight()
                        .padding(start = 16.dp),
                    onClick = {
                        scope.launch {
                            pagerState.animateScrollToPage(pages.size - 1)
                        }
                    }
                ) {
                    Text(text = stringResource(id = R.string.skip))
                }
            }

            AnimatedVisibility(
                modifier = Modifier.align(Alignment.CenterEnd),
                visible = pagerState.currentPage != pagerState.pageCount - 1,
                enter = slideInHorizontally(initialOffsetX = { it }).plus(fadeIn()),
                exit = slideOutHorizontally(targetOffsetX = { it }).plus(fadeOut())
            ) {
                Button(
                    modifier = Modifier
                        .fillMaxHeight()
                        .padding(end = 16.dp),
                    shape = CircleShape,
                    onClick = {
                        if (pagerState.currentPage + 1 < pages.size)
                            scope.launch {
                                pagerState.animateScrollToPage(pagerState.currentPage + 1)
                            }
                    }
                ) {
                    Text(
                        text = stringResource(id = R.string.next)
                    )

                    Icon(
                        modifier = Modifier.padding(start = 8.dp),
                        imageVector = Icons.Rounded.ArrowForward,
                        contentDescription = null
                    )
                }
            }

            AnimatedVisibility(
                modifier = Modifier.align(Alignment.Center),
                visible = pagerState.currentPage == pagerState.pageCount - 1,
                enter = expandHorizontally(expandFrom = Alignment.CenterHorizontally) { 0 }.plus(
                    fadeIn()
                ),
                exit = shrinkHorizontally(shrinkTowards = Alignment.CenterHorizontally) { 0 }.plus(
                    fadeOut()
                ),
            ) {
                Button(
                    modifier = Modifier
                        .fillMaxHeight()
                        .fillMaxWidth()
                        .padding(horizontal = 32.dp),
                    shape = CircleShape,
                    onClick = {
                        onBoardingViewModel.saveOnBoardingState(true)
                    }
                ) {
                    Text(text = "Finish")
                }
            }
        }
    }
}
@HiltViewModel
class OnBoardingViewModel @Inject constructor(private val dataStoreManager: DataStoreManager) :
    ViewModel() {

    fun saveOnBoardingState(completed: Boolean) {
        viewModelScope.launch(Dispatchers.IO) {
            dataStoreManager.storeValue(key = PreferenceKey.onBoardingCompleted, value = completed)
        }
    }
}

I want to the screen not to change after clicking the Finish button and content of collect function not been executed more than one time.

Please help me because i can't solve this problem for few days :(


Solution

  • I found a solution!

    The screen was changing after clicking Finish button, because the flow in SplashViewModel never stopped collecting values. Whenever i changed "onBoardingCompleted"(Boolean) value in Data Store by calling saveOnBoardingState function then that value was automatically emitted.

    To fix that we have to use .first() flow operator which collecting only first emitted value and cancels the flow instead of .collect() operator.

    Old code:

    class SplashViewModel @Inject constructor(private val dataStoreManager: DataStoreManager) :
        ViewModel() {
        private val _isLoading = MutableStateFlow(true)
        val isLoading = _isLoading.asStateFlow();
    
        private val splashEventChannel = Channel<UiEvent>()
        val splashScreenEvents = splashEventChannel.receiveAsFlow()
    
        init {
            viewModelScope.launch(Dispatchers.IO) {
                dataStoreManager.getValueFromDataStore(PreferenceKey.onBoardingCompleted)
                    .collect { completed ->
                        val route =
                            if (completed == true) NavigationDestinations.Auth.GRAPH else NavigationDestinations.OnBoarding.GRAPH
                        splashEventChannel.send(BaseUiEvent.Navigate(route))
    
                        _isLoading.value = false
                    }
            }
        }
    }
    

    Working code:

    class SplashViewModel @Inject constructor(private val dataStoreManager: DataStoreManager) :
        ViewModel() {
        private val _isLoading = MutableStateFlow(true)
        val isLoading = _isLoading.asStateFlow();
    
        private val splashEventChannel = Channel<UiEvent>()
        val splashScreenEvents = splashEventChannel.receiveAsFlow()
    
        init {
            viewModelScope.launch(Dispatchers.IO) {
                val onBoardingCompleted =
                    dataStoreManager.getValueFromDataStore(PreferenceKey.onBoardingCompleted, false)
                        .first()
    
                val route =
                    if (onBoardingCompleted) NavigationDestinations.Auth.GRAPH else NavigationDestinations.OnBoarding.GRAPH
                splashEventChannel.send(BaseUiEvent.Navigate(route))
    
                _isLoading.value = false
            }
        }
    }
    

    btw i have improved a getValueFromDataStore function in a DataStoreManager. Here is the code:

        fun <T> getValueFromDataStore(preferencesKey: Preferences.Key<T>, default: T): Flow<T> {
            return dataStore.data.catch {
                if (it is IOException) {
                    emit(emptyPreferences())
                } else {
                    throw it
                }
            }.map {
                it[preferencesKey] ?: default
            }
        }