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.
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()
}
}
}
)
}