Skip to content

Instantly share code, notes, and snippets.

@mmolosay
Last active November 12, 2025 10:31
Show Gist options
  • Select an option

  • Save mmolosay/584ce5c47567cb66228b76ef98c3c4e4 to your computer and use it in GitHub Desktop.

Select an option

Save mmolosay/584ce5c47567cb66228b76ef98c3c4e4 to your computer and use it in GitHub Desktop.
Animated button with loading indicator in Jetpack Compose
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.Transition
import androidx.compose.animation.core.VisibilityThreshold
import androidx.compose.animation.core.animateDp
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.updateTransition
import androidx.compose.animation.expandHorizontally
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkHorizontally
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
@Composable
fun LoadingButton(
onClick: () -> Unit,
loading: Boolean,
) {
val transition = updateTransition(
targetState = loading,
label = "master transition",
)
val horizontalContentPadding by transition.animateDp(
transitionSpec = {
spring(
stiffness = SpringStiffness,
)
},
targetValueByState = { toLoading -> if (toLoading) 12.dp else 24.dp },
label = "button's content padding",
)
Button(
onClick = onClick,
modifier = Modifier.defaultMinSize(minWidth = 1.dp),
contentPadding = PaddingValues(
horizontal = horizontalContentPadding,
vertical = 8.dp,
),
) {
Box(contentAlignment = Alignment.Center) {
LoadingContent(
loadingStateTransition = transition,
)
PrimaryContent(
loadingStateTransition = transition,
)
}
}
}
@OptIn(ExperimentalAnimationApi::class)
@Composable
private fun LoadingContent(
loadingStateTransition: Transition<Boolean>,
) {
loadingStateTransition.AnimatedVisibility(
visible = { loading -> loading },
enter = fadeIn(),
exit = fadeOut(
animationSpec = spring(
stiffness = SpringStiffness,
visibilityThreshold = 0.10f,
),
),
) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
color = LocalContentColor.current,
strokeWidth = 1.5f.dp,
strokeCap = StrokeCap.Round,
)
}
}
@OptIn(ExperimentalAnimationApi::class)
@Composable
private fun PrimaryContent(
loadingStateTransition: Transition<Boolean>,
) {
loadingStateTransition.AnimatedVisibility(
visible = { loading -> !loading },
enter = fadeIn() + expandHorizontally(
animationSpec = spring(
stiffness = SpringStiffness,
dampingRatio = Spring.DampingRatioMediumBouncy,
visibilityThreshold = IntSize.VisibilityThreshold,
),
expandFrom = Alignment.CenterHorizontally,
),
exit = fadeOut(
animationSpec = spring(
stiffness = SpringStiffness,
visibilityThreshold = 0.10f,
),
) + shrinkHorizontally(
animationSpec = spring(
stiffness = SpringStiffness,
// dampingRatio is not applicable here, size cannot become negative
visibilityThreshold = IntSize.VisibilityThreshold,
),
shrinkTowards = Alignment.CenterHorizontally,
),
) {
Text(
text = "Place the order",
modifier = Modifier
// so that bouncing button's width doesn't cut first and last letters
.padding(horizontal = 4.dp),
)
}
}
// use same spring stiffness so that all animations finish at about the same time
private val SpringStiffness = Spring.StiffnessMediumLow
@Preview
@Composable
private fun LoadingButtonPreview() {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
LoadingButton(
onClick = {},
loading = false,
)
}
}
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
@Composable
fun LoadingButtonUsage() {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
var loading by remember { mutableStateOf(false) }
LoadingButton(
onClick = { loading = true },
loading = loading,
)
Button(
onClick = { loading = false },
enabled = loading,
) {
Text(text = "Stop loading")
}
}
}
@Preview
@Composable
private fun LoadingButtonUsagePreview() {
LoadingButtonUsage()
}
@mmolosay
Copy link
Author

mmolosay commented Nov 2, 2023

animated-button

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment