Search code examples
androidkotlinandroid-jetpack-composejetpack-compose-modalbottomsheet

Top composable state does not seem to dribble down in ModalBottomSheet composable


In the main composable of my screen, I have the following state that holds an instance of a product that is assigned later on:

var productToPick: P2CProduct? by remember {
    mutableStateOf(null)
}

Here is the class def:

@Serializable
data class P2CProduct(
    @SerialName("barcode")
    val barcode: String? = null,
    @SerialName("exclude_from_batch")
    val excludeFromBatch: Boolean? = null,
    @SerialName("fulfilmentclient_id")
    val fulfillmentClientId: Int? = null,
    @SerialName("id")
    val id: Int? = null,
    @SerialName("is_box")
    val isBox: Boolean? = null,
    @SerialName("name")
    val name: String? = null,
    @SerialName("picklists")
    val picklists: List<Picklist>?,
    @SerialName("pivot")
    val pivot: P2CProductPivot? = null,
    @SerialName("price")
    val price: Double? = null,
    @SerialName("profile_photo_url")
    val profilePhotoUrl: String? = null,
    @SerialName("sku")
    val sku: String? = null,
    @SerialName("type")
    val type: String? = null, //product type
    @SerialName("uuid")
    val uuid: String,
) {
    var pickedAmount by Delegates.notNull<Int>()
    fun getLocationName() = pivot?.location?.name ?: "N/A"
    fun getAmountToPick() = pivot?.amount
}

In a list of products that I display, each product item is declared as:

ProductListItem(index, product) {
                            product.pickedAmount += 1
                            productToPick = product
                            batchDetailsViewModel.pickProduct(
                                batchUUID,
                                product.uuid,
                                product.pivot?.uuid!!,
                                onSuccess = {
                                    shouldShowPickProductBottomSheet = true
                                },
                                onError = {
                                    Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
                                }
                            )
                        }

When a product item in the list is clicked, it increments the products pickedAmount variable and updates the top composable's state so that I can use it in the modal bottom sheet that follows. Here is the top composable section that displays the bottom sheet:

if (shouldShowPickProductBottomSheet && picklistToPickFrom != null && productToPick != null) {
    PickProductModalBottomSheet(
        sheetState = scanProductSheetState,
        batchDetailsViewModel = batchDetailsViewModel,
        onDismissRequest = { shouldShowPickProductBottomSheet = false },
        batchUUID = batchUUID,
        picklistToPickFrom = picklistToPickFrom!!,
        productToPick = productToPick!!,
        onProductPick = {
            productToPick!!.pickedAmount += 1
        }
    ) {
        coroutineScope
            .launch {
                scanProductSheetState.hide()
            }.invokeOnCompletion {
                shouldShowPickProductsInPicklistBottomSheet = true
            }
    }
}

The bottom sheet is defined as:

@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun PickProductModalBottomSheet(
    sheetState: SheetState,
    batchDetailsViewModel: BatchDetailsViewModel,
    onDismissRequest: () -> Unit,
    batchUUID: String,
    productToPick: P2CProduct,
    picklistToPickFrom: Picklist,
    onProductPick: () -> Unit,
    onPickOtherProductsClicked: () -> Unit
) {
    val context = LocalContext.current

    ModalBottomSheet(
        onDismissRequest = onDismissRequest,
        sheetState = sheetState,
        contentColor = Color.Black,
        containerColor = colorResource(id = R.color.white),
        scrimColor = Color.DarkGray.copy(alpha = 0.4f),
        modifier = Modifier
            .wrapContentHeight()
    ) {
        Column(
            modifier = Modifier.padding(horizontal = 16.dp),
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            Text(
                picklistToPickFrom.reference ?: "N/A",
                fontSize = 24.sp,
                textAlign = TextAlign.Center,
                fontWeight = FontWeight.Bold,
            )
            Spacer(Modifier.height(32.dp))
            AsyncImage(
                model = productToPick.profilePhotoUrl,
                contentDescription = stringResource(R.string.product_image_content_desc),
                error = painterResource(id = R.drawable.no_product_available_placeholder),
                modifier = Modifier
                    .background(colorResource(id = R.color.blue_100), CircleShape)
                    .clip(CircleShape)
                    .size(128.dp)
            )
            Spacer(modifier = Modifier.height(8.dp))
            Text(
                "Pick ${productToPick.name ?: "N/A"}",
                fontSize = 22.sp,
                textAlign = TextAlign.Center,
                fontWeight = FontWeight.SemiBold,
            )
            Spacer(modifier = Modifier.height(8.dp))
            Text(
                productToPick.sku ?: "N/A",
                fontSize = 20.sp,
                textAlign = TextAlign.Center,
            )
            Spacer(Modifier.height(32.dp))
            Row(
                Modifier
                    .fillMaxWidth(),
                horizontalArrangement = Arrangement.Center,
                verticalAlignment = Alignment.CenterVertically
            ) {
                Box(
                    Modifier
                        .background(
                            color = colorResource(id = R.color.blue_100),
                            shape = RoundedCornerShape(8.dp)
                        )
                        .padding(12.dp),
                    contentAlignment = Alignment.Center
                ) {
                    Text(
                        productToPick.pivot?.location?.name ?: "N/A",
                        fontWeight = FontWeight.Bold,
                        fontSize = 20.sp
                    )
                }
                Spacer(Modifier.width(16.dp))
                Image(
                    painter = painterResource(id = R.drawable.ic_arrow_right_long),
                    contentDescription = stringResource(R.string.location_to_container_mapping_arrow_img_desc),
                    alpha = 0.5f
                )
                Spacer(Modifier.width(16.dp))
                Box(
                    Modifier
                        .background(
                            color = colorResource(id = R.color.blue_100),
                            shape = RoundedCornerShape(8.dp)
                        )
                        .padding(12.dp),
                    contentAlignment = Alignment.Center
                ) {
                    Text(
                        picklistToPickFrom.container?.reference ?: "N/A",
                        fontWeight = FontWeight.SemiBold,
                        fontSize = 18.sp,
                        overflow = TextOverflow.Ellipsis
                    )
                }
            }
            Spacer(Modifier.height(32.dp))
            ProductQuantityButton(
                Modifier
                    .width(180.dp)
                    .height(60.dp),
                compactModeEnabled = false,
                product = productToPick
            ) {
                batchDetailsViewModel.pickProduct(
                    batchUUID,
                    productToPick.uuid,
                    productToPick.pivot!!.uuid!!,
                    onSuccess = {
                        onProductPick()
                    },
                    onError = {
                        Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
                    }
                )
            }
            Spacer(modifier = Modifier.height(32.dp))
            ClickableText(
                text = buildAnnotatedString { append("Pick other products in ${picklistToPickFrom.reference}") },
                onClick = {
                    onDismissRequest()
                    onPickOtherProductsClicked()
                },
                style = TextStyle(
                    color = colorResource(id = R.color.blue_500),
                    textDecoration = TextDecoration.Underline,
                    fontSize = 16.sp
                ),
            )
            Spacer(Modifier.height(32.dp))
        }
    }
}

Here are the ProductListItem and ProductItem composables:

@Composable
fun ProductListItem(
    index: Int,
    product: P2CProduct,
    onProductPickInitiated: () -> Unit
) {
    ProductItem(
        modifier = Modifier
            .background(
                color = if (index % 2 == 0) colorResource(id = R.color.white) else colorResource(
                    id = R.color.gray_100
                )
            )
            .clickable {

            },
        batchProduct = product,
        onProductPicked = onProductPickInitiated
    )
    Divider()
}

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ProductItem(
    modifier: Modifier = Modifier,
    batchProduct: P2CProduct,
    onProductPicked: () -> Unit
) {
    var showProductTitlePopup by remember {
        mutableStateOf(false)
    }
    Row(
        modifier
            .fillMaxWidth()
            .height(IntrinsicSize.Min)
            .padding(vertical = 8.dp, horizontal = 16.dp),
        horizontalArrangement = Arrangement.SpaceEvenly,
        verticalAlignment = Alignment.CenterVertically
    ) {
        Column(
            horizontalAlignment = Alignment.Start,
            verticalArrangement = Arrangement.spacedBy(8.dp),
            modifier = Modifier.weight(0.6f)
        ) {
            Text(
                text = buildAnnotatedString {
                    append(batchProduct.name)
                },
                overflow = TextOverflow.Ellipsis,
                maxLines = 2,
                style = TextStyle(
                    fontSize = 16.sp,
                    fontWeight = FontWeight.Medium
                ),
                color = colorResource(id = R.color.blue_600),
                modifier = Modifier.combinedClickable(
                    onLongClick = {
                        showProductTitlePopup = !showProductTitlePopup
                    },
                    onClick = {}
                )
            )
            Text(
                batchProduct.sku ?: "N/A", overflow = TextOverflow.Ellipsis, maxLines = 1,
                fontSize = 15.sp
            )
        }
        if (showProductTitlePopup)
            Popup(
                alignment = Alignment.TopCenter,
                properties = PopupProperties(
                    dismissOnClickOutside = true
                ),
                onDismissRequest = {
                    showProductTitlePopup = false
                }
            ) {
                Box(
                    Modifier
                        .wrapContentWidth()
                        .height(IntrinsicSize.Min)
                        .padding(16.dp)
                        .background(colorResource(id = R.color.gray_50), RoundedCornerShape(8.dp))
                        .border(
                            1.dp,
                            colorResource(id = R.color.gray_400),
                            RoundedCornerShape(8.dp)
                        )
                ) {
                    Column(
                        modifier = Modifier
                            .fillMaxSize()
                            .padding(horizontal = 20.dp, vertical = 10.dp),
                        horizontalAlignment = Alignment.CenterHorizontally,
                        verticalArrangement = Arrangement.Center
                    ) {
                        Text(
                            text = batchProduct.name ?: "N/A",
                            modifier = Modifier.padding(vertical = 5.dp),
                            fontSize = 16.sp,
                            color = colorResource(id = R.color.blue_600)
                        )
                    }
                }
            }
        Spacer(Modifier.width(16.dp))
        Box(
            modifier = Modifier
                .background(
                    colorResource(id = R.color.blue_100), RoundedCornerShape(8.dp)
                )
                .padding(vertical = 8.dp, horizontal = 12.dp),
            contentAlignment = Alignment.Center
        ) {
            Text(
                batchProduct.getLocationName(),
                overflow = TextOverflow.Ellipsis,
                maxLines = 1,
                fontSize = 16.sp,
                fontWeight = FontWeight.SemiBold,
                color = colorResource(id = R.color.blue_800)
            )
        }
        Spacer(Modifier.width(16.dp))
        batchProduct.getAmountToPick()?.let {
            ProductQuantityButton(
                modifier = Modifier
                    .heightIn(max = 40.dp)
                    .wrapContentWidth()
                    .weight(0.5f),
                compactModeEnabled = true,
                product = batchProduct,
                onProductScannedManually = onProductPicked
            )
        }
    }
}

Here is the ProductQuantityButton composable that handles the click event for picking a product and displaying the picked amount:

@Composable
fun ProductQuantityButton(
    modifier: Modifier = Modifier,
    compactModeEnabled: Boolean = false,
    product: P2CProduct,
    onProductScannedManually: () -> Unit,
) {
    val hasScannedAllProducts by remember {
        derivedStateOf {
            product.pickedAmount == product.getAmountToPick()
        }
    }
    val pickedAmount by remember {
        derivedStateOf {
            product.pickedAmount
        }
    }
    Row(
        modifier
            .padding(horizontal = if (compactModeEnabled) 0.dp else 16.dp)
            .background(
                color = colorResource(id = if (!hasScannedAllProducts) R.color.gray_50 else R.color.green_100),
                shape = RoundedCornerShape(8.dp)
            )
            .border(
                1.dp,
                color = colorResource(id = if (!hasScannedAllProducts) R.color.gray_400 else R.color.green_500),
                shape = RoundedCornerShape(8.dp)
            ),
        verticalAlignment = Alignment.CenterVertically,
        horizontalArrangement = Arrangement.Center
    ) {
        IconButton(
            onClick = {
            },
            enabled = false,
            modifier = Modifier.wrapContentSize()
        ) {
            Icon(
                painterResource(id = R.drawable.ic_minus),
                contentDescription = stringResource(R.string.scanned_product_quantity_decr_btn_desc),
                modifier = Modifier.size(if (compactModeEnabled) 12.dp else 24.dp),
                tint = if (hasScannedAllProducts) Color.Transparent else colorResource(id = R.color.gray_400)
            )
        }
        Text(
            "${pickedAmount}/${product.getAmountToPick()}",
            textAlign = TextAlign.Center,
            fontSize = if (compactModeEnabled) 13.sp else 18.sp,
            fontWeight = FontWeight.Medium,
        )
        IconButton(
            onClick = onProductScannedManually,
            enabled = !hasScannedAllProducts,
            modifier = Modifier.wrapContentSize()
        ) {
            Icon(
                painter = painterResource(id = if (!hasScannedAllProducts) R.drawable.ic_plus else R.drawable.ic_checkmark),
                contentDescription = stringResource(id = R.string.scanned_product_quantity_incr_btn_desc),
                tint = if (hasScannedAllProducts) Color.Transparent else colorResource(id = R.color.gray_400),
                modifier = Modifier.size(if (compactModeEnabled) 12.dp else 24.dp)
            )
        }
    }
}

Now here is the issue, the updated value of the pickedAmount is displayed properly when the picking is iniated via the product quantity button's onClick in the list items. However, when using the same component (productQuantityButton) in the modal sheet, the value change isn't being updated.

I know that right now I am simply passing the picked product to the modal sheet as is and that simply changing a field value does not force recomposition, trying with a .copy().also { // update pickedAmount} statement, doesn't seem to trigger the recomposition either and thus leads to the pickedAmount value being displayed in the bottom sheet, to not be up-to-date.

What am I missing here as far as state management goes?

P.S. Video to help visualize the issue


Solution

  • The code you provided is quite verbose and as no one answered yet, I'll give it a shot.

    To me, it seems like you infringe the unidirectional data flow pattern (UDF) which is recommended in Jetpack Compose.
    UDF says that data should flow down the Composable hierarchy by passing it as a function parameter, and events should flow upwards by the child Composables calling the callback functions they were provided with.

    I sometimes experienced strange issues with Compose until it turned out that I was hurting that principle. Maybe it's the same case here.

    See the following BAD PRACTICE code sample:

    @Composable
    fun ParentComposable() {
        val amount by remember { mutableStateOf(1) } 
    
        Text(text = "€" + amount)
        ChildComposable(amount)
    }
    
    @Composable
    fun ChildComposable(val amount) {
    
        Button(onClick = { amount++ }) {
            Text("INCREASE")
        }
    }
    

    The data in this code flows downwards and upwards, as the child is modified the data that was passed in directly.


    Now see the following GOOD PRACTICE code sample:

    @Composable
    fun ParentComposable() {
        val amount by remember { mutableStateOf(1) } 
    
        Text(text = "€" + amount)
        ChildComposable(amount) { newAmount ->
            amount = newAmount
        }
    }
    
    @Composable
    fun ChildComposable(val amount, onAmountChange: (newAmount: Int) -> Unit) {
    
        Button(onClick = { onAmountChange(++amount) }) {
            Text("INCREASE")
        }
    }
    

    In this case, the data flows downwards, and events flow upwards. Data changes are transported alongside the events. This is the way that Compose encourages you to communicate between parent and child Composables.

    I'd suggest that you try to change your ProductQuantifyButton Composable to communicate to its parents using UDF with callbacks and see if the issue persists.