Search code examples
androidkotlinmvvmandroid-viewmodelkotlin-stateflow

Android Kotlin/Compose ViewModel - two SateFlows for two separate fucntions to one StateFlow for UI ViewModel?


Firstly, I'm not a developer or anything, but I wanted an app that doesn't exist for my phone and I understand a minimal amount of coding, went through the Android Kotlin/Jetpack Compose basics course, to bear with me because I only half understand what I am talking about and don't always know the proper terms. Sorry in advance for how long this is, but I'm trying to be clear in what's going on and don't know enough to know how to make it more concise or what's going to be relevant or not.

Context is this, I am trying to make an app just for me to fit what I want it to do, this isn't for the general public. It's going to be a database collection app with a top app bar showing the name of the screen (though there's only two screens), a bottom app bar with 4 icons in order: navigate to collection screen, navigate to stats screen, apply SQL filter fuction (on either collection or stats screens or both simultaneously or something, a pop-up card drawer thing with options), add to database fuction (bring up a card or something for entry). Then a content body in-between those app bars. There's only two screens, a home screen and a stats screen. I've only started with some of the arcitecturing, have started setup for a Dao, etc. I actually started with the cupcake inventory app as a basis for starting this.

I want the body of the home screen to start with a row that contains a toggle icon to switch between a table view and a list view. Problem is, my ViewModel already has a StateFlow for calling the database list itself, and the example for the switch is also itself a state flow in the view model, so I don't know how to get this StateFlow and the other StateFlow both passing into the ViewModel StateFlow but staying separate for their own parts? I was trying to find some kind of example that shows having two things like this that are separate things. I have a database for the collection itself and a preferences repository for storing the view mode (table vs list). So far that's all this repository is set up for.

So then I looked at the Dessert Clicker sample for the view change switch and the arcitecture was different, but I was able to get it where everything needed to go, but then I tried to figure out how to add the switch junk into the view model and what I ended up with doesn't give errors in studio and actually compiles, but then crashes when I run the emulator. This is what I tried to do, but it clearly doesn't work and I'm not really understanding StateFlows very well.

I'm not sure if it's the ViewModel not being done correctly, or the implementation in the HomeScreen that's causing the crash when I try to run it in the emulator. I also could not find a single example anywhere that shows a ViewModel like this or anything using the "merge" property (fuction?) and I'm kind of... stumped. Is this even the right way to go about the ViewModel?

Current viewmodel combination attempt HomeViewModel.kt:

class HomeViewModel(
    itemsRepository: ItemsRepository,
    private val preferencesRepo: PreferencesRepo
): ViewModel() {
    companion object {
        private const val TIMEOUT_MILLIS = 5_000L
    }

    val homeUiState: StateFlow<HomeUiState> =
        merge(
            itemsRepository.getAllItemsStream().map { HomeUiState(it) }
                .stateIn(
                    scope = viewModelScope,
                    started = SharingStarted.WhileSubscribed(TIMEOUT_MILLIS),
                    initialValue = HomeUiState()
                ),
            preferencesRepo.isTableView.map { isTableView -> HomeUiState(isTableView = isTableView) }
                .stateIn(
                    scope = viewModelScope,
                    started = SharingStarted.WhileSubscribed(TIMEOUT_MILLIS),
                    initialValue = HomeUiState()
                )
        )as StateFlow<HomeUiState>

    /* Toggle Cellar View */
    fun selectView(isTableView: Boolean) {
        viewModelScope.launch {
            preferencesRepo.saveViewPreference(isTableView)
        }
    }
}

data class HomeUiState(
    val items: List<Items> = listOf(),
    val isTableView: Boolean = true,
    val toggleContentDescription: Int =
        if (isTableView) R.string.table_view_toggle else R.string.list_view_toggle,
    val toggleIcon: Int =
        if (isTableView) R.drawable.list_view else R.drawable.table_view
)

And the HomeScreen.kt (sorry for the code being a huge mess, ignore some of the dumb values like titles in non-title places and big .dp sizes, that's just me exaggerating things so I know what is affecting what and where):

object HomeDestination : NavigationDestination {
    override val route = "home"
    override val titleRes = R.string.home_title
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen(
    navigateToHome: () -> Unit,
    navigateToStats: () -> Unit,
    modifier: Modifier = Modifier,
    viewmodel: HomeViewModel = viewModel(factory = AppViewModelProvider.Factory)
) {
    val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
    val homeUiState by viewmodel.homeUiState.collectAsState()

    Scaffold(
        modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
        topBar = {
            CellarTopAppBar(
                title = stringResource(HomeDestination.titleRes),
                scrollBehavior = scrollBehavior,
                canNavigateBack = false,
            )
        },
        bottomBar = {
            CellarBottomAppBar(
                navigateToHome = navigateToHome,
                navigateToStats = navigateToStats,
            )
        },
    ) { innerPadding ->
        HomeBody(
            items = homeUiState.items,
            homeUiState = homeUiState,
            selectView = viewmodel::selectView,
            modifier = modifier.fillMaxSize(),
            contentPadding = innerPadding,
        )
    }
}


@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun HomeBody(
    items: List<Items>,
    homeUiState: HomeUiState,
    selectView: (Boolean) -> Unit,
    modifier: Modifier = Modifier,
    contentPadding: PaddingValues = PaddingValues(8.dp),
) {
    Column(
        modifier = modifier
            .fillMaxWidth()
    ) {
        Row(
            modifier = Modifier
                .fillMaxWidth(),
            horizontalArrangement = Arrangement.spacedBy(8.dp),
        ) {
            //Switch table/list view//
            Text(
                text ="Switch View:",
                textAlign = TextAlign.Start,
                style = MaterialTheme.typography.titleSmall,
                modifier = Modifier.padding(contentPadding),
                )
            IconButton(
                onClick = {
                    val isTableView = true
                    selectView(!isTableView)
                }
            ) {
                Icon(
                    painter = painterResource(homeUiState.toggleIcon),
                    contentDescription = stringResource(R.string.list_view_toggle),
                    tint = onPrimaryContainerLight,
                )
            }

            //Search bar//
            Text(
                text = "(TODO \"Search bar\")",
                textAlign = TextAlign.Start,
                style = MaterialTheme.typography.titleSmall,
                modifier = Modifier.padding(contentPadding),
                )
            /* TODO Search bar */
        }
        if (items.isEmpty()) {
            Text(
                text = stringResource(R.string.no_items),
                textAlign = TextAlign.Center,
                style = MaterialTheme.typography.titleLarge,
                modifier = Modifier.padding(contentPadding),
            )
        } else {
            if (homeUiState.isTableView) {
                TableViewMode()
            } else {
                ListViewMode()
            }
        }
    }
}



/* View Mode functions */

@Composable
fun TableViewMode(
    modifier: Modifier = Modifier,
    contentPadding: PaddingValues = PaddingValues(0.dp),
){
    /* TODO Table View */
}

@Composable
fun ListViewMode(
    modifier: Modifier = Modifier,
    contentPadding: PaddingValues = PaddingValues(0.dp),
){
    /* TODO List View */
}

File structure: file structure


Solution

  • If you merge two StateFlows, you get just a vanilla Flow (a cold one that will start collecting from the two hot StateFlows when it starts getting collected). That's because the merge function is just a regular Flow operator that doesn't pay attention to whether the Flows you pass to it are SharedFlows/StateFlows or not.

    You didn't say what kind of crash you got, but I'm assuming it was a ClassCastException since you tried to cast a regular Flow into a StateFlow. This is not the sort of situation where you should be using as. Those situations are rare and when a beginner uses as, they are probably using it incorrectly.

    The correct way to merge them is to merge the two cold vanilla Flows first, and then call stateIn on them:

    val homeUiState: StateFlow<HomeUiState> =
        merge(
            itemsRepository.getAllItemsStream().map { HomeUiState(it) },
            preferencesRepo.isTableView.map { isTableView -> HomeUiState(isTableView = isTableView) }
        ).stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(TIMEOUT_MILLIS),
            initialValue = HomeUiState()
        )
    

    This creates a hot StateFlow that, when subscribed to, starts collection of the upstream flow (a merged flow that starts collecting from its upstream flows when it is collected).

    I didn't check your Composable code.