Jetpack Compose :Managing UI State Changes in our ViewModel

Jetpack Compose :Managing UI State Changes in our ViewModel

Introduction

This blog aims to explain how to manage, inside a ViewModel, ** mutable states that changes a Jetpack Compose UI**.

To achieve this management, we'll be using a single data class that stores the UI mutable states.

We'll be using as example, a single compose screen app, that displays a list of clothing items, that has a loading indicator before displaying the clothing items. The list of clothing items displayed on the UI can change based on the selected type of category of clothing on the UI.

As you can see in the below image, our UI state rely on 3 key components: the data loading state, the change of the clothing category state which can trigger a change in the state of the displayed list of clothes [listOf<Clothing>()]

compose_state_mngmnt_thumbnail_1.png

Create a Data class that stores the UI mutable states

As mentioned in the above image, we have three UI states that can change: the loading state, the category of clothes that should be displayed on the Home Screen and the displayed list of clothes that changes based on the clothing category chosen.

data class HomeScreenState(
    var isLoading: Boolean = false,
    var clothesList: List<Clothing> = emptyList(),
    var clothingCategory: ClothingCategory = ClothingCategory.AllClothes
)

data class Clothing(
    val id: Int,
    val title: String,
    val description: String,
    val price: Float,
    val category: String,
    val image: String,
)

sealed interface ClothingCategory {
    object AllClothes : ClothingCategory
    object MenClothing : ClothingCategory
    object WomenClothing : ClothingCategory
}

Next Create a ViewModel to reflect Home Screen State Changes to the HomeScreen UI

class HomeScreenViewModel(
    private val repository: ClothesRepository
) : ViewModel() {

    var homeScreenState = mutableStateOf(HomeScreenState())
        private set

    fun getClothesByCategory(clothingCategory: ClothingCategory) =
        viewModelScope.launch {
            homeScreenState.value = homeScreenState.value.copy(isLoading = true)
            repository
                .getClothesByCategory(
                    clothingCategory, result = { clothes ->
                        homeScreenState.value = homeScreenState.value.copy(
                            isLoading = false,
                            clothesList = clothes,
                            clothingCategory = clothingCategory
                        )
                    })
        }

    init {
        getClothesByCategory(ClothingCategory.AllClothes)
    }
}

So what have we done so far?

  • We've created a data class (var homeScreenState = mutableStateOf(HomeScreenState())) to track the three states that changes our UI.

  • We've created a ViewModel that has a (fun getClothesByCategory(clothingCategory: ClothingCategory)) function which updates any changes necessary to our data class

By default, when a call to the ViewModel's getClothesByCategory(clothingCategory: ClothingCategory) is made, a list of all clothing items will be asserted to the ViewModel's homeScreenState.clothesList because of the ClothingCategory.AllClothes passed by default as argument in this function.

But, we also want to give the freedom to the user to choose to display at a time a list of men's clothing or a list of women's clothing too. To do that, we will create a Composable function with FilterChip buttons that will trigger a call to our ViewModel's getClothesByCategory(clothingCategory: ClothingCategory) function, every time a click is done on one of them:

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChooseClothingCategory(
    homeScreenState: HomeScreenState,
    onClick: (ClothingCategory) -> Unit
) {
    val context = LocalContext.current
    val categoriesMap = remember {
        mutableStateOf(
            mapOf(
                ClothingCategory.AllClothes to context.getString(R.string.all_clothes),
                ClothingCategory.MenClothing to context.getString(R.string.men_clothing),
                ClothingCategory.WomenClothing to context.getString(R.string.women_clothing)
            )
        )
    }
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .horizontalScroll(rememberScrollState())
            .padding(start = 16.dp, top = 4.dp, bottom = 2.dp),
        verticalAlignment = Alignment.CenterVertically,
        horizontalArrangement = Arrangement.Start,
    ) {
        for (category in categoriesMap.value) {
            Spacer(modifier = Modifier.width(4.dp))
            FilterChip(
                selected = homeScreenState.clothingCategory == category.key,
                onClick = { onClick(category.key) },
                label = { Text(text = category.value) }
            )
        }
        Spacer(modifier = Modifier.width(4.dp))
    }
}

compose_state_mngmnt_thumbnail_2.png

Now, let's create the ClothingItem

@Composable
fun ClothingItem(
    modifier: Modifier = Modifier,
    clothingItem: ClothingItem,
    onClothingItemClick: () -> Unit
) {
    Card(
        modifier = modifier.clickable { onClothingItemClick() },
        elevation = 6.dp,
    ) {
        Box(modifier = Modifier.background(MaterialTheme.colorScheme.surface)) {
            Column(modifier = Modifier.background(MaterialTheme.colorScheme.surface)) {
                AsyncImage(
                    modifier = Modifier
                        .fillMaxWidth()
                        .height(300.dp),
                    model = ImageRequest.Builder(LocalContext.current).data(clothingItem.image).build(),
                    placeholder = painterResource(id = R.drawable.ic_baseline_image_24),
                    contentDescription = stringResource(id = R.string.product_image),
                    contentScale = ContentScale.Crop,
                )
                Spacer(modifier = Modifier.height(4.dp))
                Text(
                    text = clothingItem.title,
                    fontSize = 16.sp,
                    fontWeight = FontWeight.Medium,
                    color = MaterialTheme.colorScheme.onSurface,
                    maxLines = 2,
                    overflow = TextOverflow.Ellipsis,
                    modifier = Modifier.padding(start = 8.dp, end = 8.dp, top = 8.dp)
                )
                Spacer(modifier = Modifier.height(4.dp))
                Text(
                    text = "${clothingItem.price} $",
                    fontSize = 14.sp,
                    color = MaterialTheme.colorScheme.primary,
                    maxLines = 1,
                    fontWeight = FontWeight.Medium,
                    overflow = TextOverflow.Ellipsis,
                    modifier = Modifier.padding(start = 8.dp, end = 8.dp, bottom = 8.dp)
                )
                Text(
                    text = clothingItem.description,
                    fontSize = 14.sp,
                    color = MaterialTheme.colorScheme.onSurface,
                    maxLines = 2,
                    overflow = TextOverflow.Ellipsis,
                    modifier = Modifier.padding(start = 8.dp, end = 8.dp, bottom = 8.dp)
                )
                Row(
                    modifier = Modifier
                        .padding(start = 8.dp, end = 8.dp, top = 8.dp, bottom = 8.dp)
                ) {
                    OutlinedButton(
                        onClick = {  },
                        modifier = Modifier
                            .padding(end = 8.dp)
                            .weight(1f),
                        shape = RoundedCornerShape(10)
                    ) {
                        if (clothingItem.addedToCart) {
                            Icon(
                                imageVector = Icons.Default.Done,
                                contentDescription = "Checked icon",
                                tint = MaterialTheme.colorScheme.primary,
                                modifier = Modifier.padding(end = 4.dp)
                            )
                        } else {
                            Icon(
                                imageVector = Icons.Default.AddShoppingCart,
                                contentDescription = "Add Shopping Cart icon",
                                tint = MaterialTheme.colorScheme.primary,
                                modifier = Modifier.padding(end = 4.dp)
                            )
                        }
                        Text(
                            text =
                            if (clothingItem.addedToCart) stringResource(id = R.string.added_to_cart)
                            else stringResource(
                                id = R.string.add_to_cart
                            ),
                            color = MaterialTheme.colorScheme.primary,
                            fontWeight = FontWeight.Medium
                        )
                    }
                }
            }
            FloatingActionButton(
                modifier = Modifier
                    .padding(horizontal = 8.dp, vertical = 4.dp)
                    .align(Alignment.TopEnd),
                onClick = { },
                shape = CircleShape,
                containerColor = MaterialTheme.colorScheme.primary,
            ) {
                if (clothingItem.addedAsFavorite) {
                    Icon(
                        imageVector = Icons.Default.Favorite,
                        contentDescription = stringResource(id = R.string.added_to_favorites) + " icon",
                        tint = MaterialTheme.colorScheme.onPrimary,
                    )
                } else {
                    Icon(
                        imageVector = Icons.Default.FavoriteBorder,
                        contentDescription = stringResource(id = R.string.add_to_favorites) + " icon",
                        tint = MaterialTheme.colorScheme.onPrimary,
                    )
                }
            }
        }
    }
}

compose_state_mngmnt_thumbnail_3.png

Now, let's create our HomeScreen composable UI

@Composable
fun HomeScreen(homeViewModel: HomeViewModel) {
    val scaffoldState = rememberScaffoldState()
    val homeScreenState by remember { homeViewModel.homeScreenState }

    Scaffold(
        scaffoldState = scaffoldState,
        content = { contentPadding ->
            LazyColumn(
                modifier = Modifier
                    .fillMaxSize()
                    .background(MaterialTheme.colorScheme.background)
                    .padding(contentPadding)
            ) {
                item {
                    if (homeScreenState.isLoading) {
                        Column(
                            modifier = Modifier
                                .fillMaxSize()
                                .aspectRatio(1f),
                            verticalArrangement = Arrangement.Center,
                            horizontalAlignment = Alignment.CenterHorizontally
                        ) {
                            CircularProgressIndicator()
                        }
                    }
                    if (homeScreenState.clothesList.isNotEmpty()) {
                        ChooseClothingCategory(homeScreenState, onClick = { category ->
                            homeViewModel.getProducts(clothingCategory = category)
                        })
                    }
                }
                items(homeScreenState.clothesList) { clothingItem ->
                    ClothingItem(clothingItem = clothingItem)
                }
            }
        })
}