androidkotlinandroid-jetpack-compose

How prevent CircularProgressIndicator child from expanding the parent in Jetpack Compose?


I am trying to add ProgressIndicator onto a Button. I would like the progress indicator to take up the whole height of the button with 5.dp vertical padding, where the height of the button is determined by the content.

So I added the isLoading: Boolean parameter and I update the contentPadding based on that. I decided to leave the content in the composition and just set .alpha(0f) on it to keep the size of the button same. But I can't get the progress indicator to be the right size. If I put .matchParentHeight() modifier on it, it inherits the height of the content itself (does not fill the whole height of the button up to the padding) or it just overflows ouside of the button. Alternatively if I remove this modifier, the indicator increases the height of the button when tapped.

How to do it?

@Composable
fun Button(
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    enabled: Boolean = true,
    isLoading: Boolean = false,
    shape: Shape = ButtonDefaults.shape,
    colors: ButtonColors = ButtonDefaults.buttonColors(),
    elevation: ButtonElevation? = ButtonDefaults.buttonElevation(),
    border: BorderStroke? = null,
    contentPadding: PaddingValues = ButtonDefaults.ContentPadding,
    interactionSource: MutableInteractionSource? = null,
    content: @Composable BoxScope.() -> Unit,
) {
    val layoutDirection = LocalLayoutDirection.current
    androidx.compose.material3.Button(
        onClick = onClick,
        modifier = modifier
        ,enabled = enabled,
        shape = shape,
        colors = colors,
        elevation = elevation,
        border = border,
        contentPadding = if(!isLoading) {
            contentPadding
        } else {
            PaddingValues(
                start = contentPadding.calculateStartPadding(layoutDirection),
                end = contentPadding.calculateEndPadding(layoutDirection),
                top = 5.dp,
                bottom = 5.dp
            )
        },
        interactionSource = interactionSource,
    ) {
        Box(
            contentAlignment = Alignment.Center,
        ) {
            val contentAlpha = if (isLoading) { 0f } else { 1f }
            Box(
                modifier = Modifier
                    .alpha(contentAlpha)
            ) {
                content()
            }

            if (isLoading) {
                CircularProgressIndicator(
                    color = if (enabled) {
                        colors.contentColor
                    } else {
                        colors.disabledContentColor
                    },
                    modifier = Modifier
                        .matchParentSize()
                        .background(Color.Yellow)
                )
            }
        }
    }
}

Edit: I am using this to preview it:

@Preview
@Composable
fun ButtonPreview() {
    Column {
        Button(
            onClick = {},
            isLoading = false,
        ) {
            Text("Test")
        }
        Button(
            onClick = {},
            isLoading = true,
        ) {
            Text("Test")
        }
        androidx.compose.material3.Button(
            onClick = {},
        ) {
            Text("Test")
        }
    }
}

Edit 2:

My goal is to have the loading button behave in a same way the default button does. But the text does not always use the full available height on the button, so I would like the progress indicator to use all the available height while keeping the button same size.


Solution

  • Answer from @tyg has a small issue where the progress indicator can appear outside of the visible button. I managed to improve it with onSizeChanged modifier. But this is not recomended by the docs:

    There are no guarantees onSizeChanged will not be re-invoked with the same size.

    Using the onSizeChanged size value in a MutableState to update layout causes the new size value to be read and the layout to be recomposed in the succeeding frame, resulting in a one frame lag.

    You can use onSizeChanged to affect drawing operations. Use Layout or SubcomposeLayout to enable the size of one component to affect the size of another.

    But I could not figure it out using Layout or SubcomposeLayout.

    Edit: It still had an issue when explicitly setting .height(...) modifier on the button. I did manage to fix it using Layout, but I could not avoid using the onSizeChanged modifier anyway. Here is the new code.

    @Composable
    fun Button(
        onClick: () -> Unit,
        modifier: Modifier = Modifier,
        enabled: Boolean = true,
        isLoading: Boolean,
        shape: Shape = ButtonDefaults.shape,
        colors: ButtonColors = ButtonDefaults.buttonColors(),
        elevation: ButtonElevation? = ButtonDefaults.buttonElevation(),
        border: BorderStroke? = null,
        contentPadding: PaddingValues = ButtonDefaults.ContentPadding,
        interactionSource: MutableInteractionSource? = null,
        content: @Composable () -> Unit,
    ) {
        val verticalIndicatorPadding = 5.dp
    
        val layoutDirection = LocalLayoutDirection.current
    
        var contentHeight by remember { mutableIntStateOf(0) }
    
        Layout(
            modifier = modifier
                .minimumInteractiveComponentSize(),
            measurePolicy = { measurables, constraints ->
                val measuredContentButtonPlaceable = measurables[2].measure(constraints)
    
                val paddingTop = contentPadding.calculateTopPadding().roundToPx()
                val paddingBottom = contentPadding.calculateBottomPadding().roundToPx()
                val paddingVertical = paddingTop + paddingBottom
                val layoutWidth = measuredContentButtonPlaceable.width
                val layoutHeight = if (constraints.hasFixedHeight) {
                    measuredContentButtonPlaceable.height
                } else {
                    // Button has invisible "minimumInteractiveComponentSize" area around it,
                    // therefore the real visual height has to be calculated like this
                    val realContentButtonHeight = contentHeight + paddingVertical
                    max(realContentButtonHeight, ButtonDefaults.MinHeight.roundToPx())
                }
                val indicatorButtonConstraints = Constraints.fixed(layoutWidth, layoutHeight)
                val contentButtonPlaceable = measurables[0].measure(indicatorButtonConstraints)
                val indicatorButtonPlaceable = measurables[1].measure(indicatorButtonConstraints)
                layout(layoutWidth, layoutHeight) {
                    if (isLoading) {
                        indicatorButtonPlaceable.place(0, 0)
                    } else {
                        contentButtonPlaceable.place(0, 0)
                    }
                }
            },
            content = {
                // 0: contentButton
                // Button with content inside
                androidx.compose.material3.Button(
                    onClick = onClick,
                    modifier = Modifier,
                    enabled = enabled,
                    shape = shape,
                    colors = colors,
                    elevation = elevation,
                    border = border,
                    contentPadding = contentPadding,
                    interactionSource = interactionSource
                ) {
                    content()
                }
    
                // 1: indicatorButton
                // Button with progress indicator inside
                androidx.compose.material3.Button(
                    onClick = {},
                    modifier = Modifier,
                    enabled = enabled,
                    shape = shape,
                    colors = colors,
                    elevation = elevation,
                    border = border,
                    contentPadding = PaddingValues(
                        start = contentPadding.calculateStartPadding(layoutDirection),
                        end = contentPadding.calculateEndPadding(layoutDirection),
                        top = verticalIndicatorPadding,
                        bottom = verticalIndicatorPadding,
                    ),
                    interactionSource = interactionSource
                ) {
                    CircularProgressIndicator(
                        color = if (enabled) {
                            colors.contentColor
                        } else {
                            colors.disabledContentColor
                        },
                        modifier = Modifier
                            .fillMaxHeight()
                            .size(0.dp)
                            .aspectRatio(1f, true)
                    )
                }
    
                // 2: measuredContentButton
                // It is used to measure the preferred size of contentButton, but is not placed
                // Also the onSizeChanged modifier is used to measure the real content height
                androidx.compose.material3.Button(
                    onClick = {},
                    modifier = Modifier,
                    enabled = enabled,
                    shape = shape,
                    colors = colors,
                    elevation = elevation,
                    border = border,
                    contentPadding = contentPadding,
                    interactionSource = interactionSource
                ) {
                    Box(
                        modifier = Modifier
                            .onSizeChanged {
                                contentHeight = it.height
                            }
                    ) {
                        content()
                    }
                }
            }
        )
    }