androidmvvmandroid-jetpack-composestateviewmodel

Android Compose Using Merged ViewModel, UI and State Issues (function breaking and weird behavior)


I'm not even sure what my actual question or what a good title for this other than generally, how, wtf? I'm not a developer, I'm just trying to make an app for myself. I tried using the debugger with code/function/line breaks and stepping in and out but, I don't understand the log at all or what's going on.

So context is this:
Trying to make an offline database app for tracking a collection of something. The desired behavior I want is when the app is launched, it goes to the "home" view that lists the data in the "saved" (user preferences datastore) view mode. I have made a switch to change between two different data layout views (table or list) within this home page. I've basically tried combining the Inventory and Dessert Release apps from the code labs as a starting point.

Here's the current behavior of what's happening:
Including a short video (screen recording):
https://www.youtube.com/watch?v=2zG9WwfOuSg

The app starts, screen is blank for a few seconds, then I see the screen refresh and I can briefly see the data table before it gets replaced with my "if list is empty, display add items" thing (even though there IS data in the database [Room, Dao]). So at launch, the list isn't populating for some reason (it only populates if I re-navigate to this same screen), regardless of whether I have the default to be list or table view (but there's other strange behavior with regard to that).

At this point, if I click the switch to change views, the switch functions like it should in that the icon changes back and forth when clicked. However, at no point does the data in the database display below, and this switch only works when the list view is empty (or at least when it thinks the list is empty, which, it is not).

It's only when I click on the home icon bottom bar navigation (this isn't a nav bar, it's a custom bottom app bar I wrote), that the view refreshes and the data table is shown (I did add data to the database before), but then once the table shows, the view switch doesn't work anymore. With one exception... if I change the default value for isTableView to "false", the same behavior happens except on the list view, however with the list view displayed, I can click the view change button and the view switch icon will change but it goes back to the "empty list" condition and the switch icon changes, but I still have to re-navigate to the home screen to get the list view to come back up. I am assuming I've got something wrong with my table view (it's the maven central oleksandrbalan / lazytable I got from gitHub).

I've tried swapping code around and changing some orders but I can't figure out how it's breaking and I've been toying with this for two weeks and can't figure it out.

So, I'm going to give what I am assuming is all of the relevant code, but if other code is needed to be seen, I can edit this and add that in. For example, I didn't include the construction code for table or list view mode or anything, but it's on the same page lower.

HomeScreen:

fun HomeScreen(
    navigateToHome: () -> Unit,
    navigateToStats: () -> Unit,
    navigateToAddEntry: () -> Unit,
    navigateToEditEntry: (Int) -> Unit,
    modifier: Modifier = Modifier,
    viewmodel: HomeViewModel = viewModel(factory = AppViewModelProvider.Factory),
) {
    val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
    val homeUiState by viewmodel.homeUiState.collectAsState()
    val items = homeUiState.items
    val isTableView = homeUiState.isTableView

 Scaffold(
        modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
        topBar = {
            CellarTopAppBar(
                title = stringResource(HomeDestination.titleRes),
                scrollBehavior = scrollBehavior,
                canNavigateBack = false,
            )
        },
        bottomBar = {
            CellarBottomAppBar(
                modifier = Modifier
                    .padding(0.dp),
                navigateToHome = navigateToHome,
                navigateToStats = navigateToStats,
                navigateToAddEntry = navigateToAddEntry,
            )
        },
    ) { innerPadding ->
        Column(
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.Top,
            modifier = Modifier
                .fillMaxSize()
                .padding(top = 64.dp, bottom = 66.dp, start = 0.dp, end = 0.dp)
        ) {
            HomeHeader(
                homeUiState = homeUiState,
                selectView = viewmodel::selectView,
                isTableView = isTableView,
            )
            HomeBody(
                homeUiState = homeUiState,
                items = items,
                isTableView = isTableView,
                onItemClick = navigateToEditEntry,
                modifier = modifier
                    .fillMaxWidth()
                    .padding(0.dp),
                contentPadding = innerPadding,
            )
        }
    }
}

@Composable
private fun HomeHeader(
    modifier: Modifier = Modifier,
    homeUiState: HomeUiState,
    selectView: (Boolean) -> Unit,
    isTableView: Boolean,
) {
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .padding(8.dp),
        verticalAlignment = Alignment.CenterVertically,
    ) {
        //Switch table/list view//
        Text(
            text ="View:",
            textAlign = TextAlign.Start,
            fontWeight = FontWeight.Normal,
            fontSize = 16.sp,
            modifier = Modifier
                .padding(0.dp)
        )
        IconButton(
            onClick = { selectView(!isTableView) },
            modifier = Modifier
                .padding(2.dp)
                .size(28.dp)
                .align(Alignment.Bottom)
        ) {
            Icon(
                painter = painterResource(homeUiState.toggleIcon),
                contentDescription = stringResource(homeUiState.toggleContentDescription),
                tint = MaterialTheme.colorScheme.onBackground,
                modifier = Modifier
                    .size(24.dp)
                    .padding(0.dp)
            )
        }
    }
}

@Composable
private fun HomeBody(
    modifier: Modifier = Modifier,
    homeUiState: HomeUiState,
    items: List<Items>,
    isTableView: Boolean,
    onItemClick: (Int) -> Unit,
    contentPadding: PaddingValues = PaddingValues(0.dp),
) {
    Column(
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Top,
        modifier = Modifier
            .fillMaxWidth()
            .padding(0.dp)
    ) {
        if (items.isEmpty()) {
            Column(
                horizontalAlignment = Alignment.CenterHorizontally,
                verticalArrangement = Arrangement.Center,
                modifier = Modifier
                    .fillMaxSize()
            ) {
                Text(
                    text = stringResource(R.string.no_items),
                    textAlign = TextAlign.Center,
                    style = MaterialTheme.typography.titleLarge,
                    modifier = Modifier
                        .padding(0.dp),
                    )
            }
        }
        else {
            if (isTableView) {
                TableViewMode(
                    itemsList = homeUiState.items,
                    contentPadding = contentPadding,
                    modifier = Modifier
                        .padding(0.dp),
                    )
            }
            else {
                ListViewMode(
                    itemsList = homeUiState.items,
                    onItemClick = { onItemClick(it.id) },
                    contentPadding = contentPadding,
                    modifier = Modifier
                        .padding(0.dp)
                        .fillMaxWidth()
                    )
            }
        }
    }
}

@Composable
fun TableViewMode(
    itemsList: List<Items>,
    contentPadding: PaddingValues,
    modifier: Modifier = Modifier,
){
    CellarLazyTable(
        itemsTbl = itemsList,
        contentPadding = contentPadding,
        modifier = modifier
            .fillMaxWidth()
            .padding(0.dp)
    )

}


@Composable
fun ListViewMode(
    itemsList: List<Items>,
    onItemClick: (Items) -> Unit,
    contentPadding: PaddingValues = PaddingValues(0.dp),
    modifier: Modifier = Modifier,
){
    LazyColumn(
        modifier = modifier
            .fillMaxWidth()
            .padding(0.dp),
    ){
        items(items = itemsList, key = { it.id }) { item ->
            CellarListItem(
                item = item,
                modifier = Modifier
                    .padding(0.dp)
                    .clickable {
                        onItemClick(item)
                    }
            )
        }
    }
}

HomeViewModel:

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) },
            preferencesRepo.isTableView.map { isTableView -> HomeUiState(isTableView = isTableView) },
        ).stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(TIMEOUT_MILLIS),
            initialValue = 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.list_view_toggle else R.string.table_view_toggle,
    val toggleIcon: Int =
        if (isTableView) R.drawable.list_view else R.drawable.table_view,
)

Solution

  • merge is just taking the latest instance from either of the flows you are using with it - if you add an onEach between your merge and stateIn, you'll find that your HomeUiState(isTableView = isTableView) instance (that has no data) is being replaced with your HomeUiState(it) and vice versa as your data changes - merge is merging the streams of data, it doesn't know how to magically combine your two HomeUiState objects.

    What you probably want instead of merge is combine, since that is what lets you write the code to create your final HomeUiState object:

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