Skip to content

Instantly share code, notes, and snippets.

@fvilarino
Last active November 24, 2023 17:53
Show Gist options
  • Save fvilarino/7dc026b8a590ef65744180b984587ede to your computer and use it in GitHub Desktop.
Save fvilarino/7dc026b8a590ef65744180b984587ede to your computer and use it in GitHub Desktop.
Loading Button Final
enum class AnimationType {
Bounce,
LazyBounce,
Fade,
}
private const val NumIndicators = 3
private const val IndicatorSize = 12
private const val BounceAnimationDurationMillis = 300
private const val FadeAnimationDurationMillis = 600
@Composable
fun LoadingButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
loading: Boolean = false,
animationType: AnimationType = AnimationType.Bounce,
colors: ButtonColors = ButtonDefaults.buttonColors(),
indicatorSpacing: Dp = MarginSingle,
content: @Composable () -> Unit,
) {
val contentAlpha by animateFloatAsState(targetValue = if (loading) 0f else 1f)
val loadingAlpha by animateFloatAsState(targetValue = if (loading) 1f else 0f)
Button(
onClick = onClick,
modifier = modifier,
enabled = enabled,
colors = colors,
) {
Box(
contentAlignment = Alignment.Center,
) {
LoadingIndicator(
animating = loading,
modifier = Modifier.graphicsLayer { alpha = loadingAlpha },
color = colors.contentColor(enabled = enabled).value,
indicatorSpacing = indicatorSpacing,
animationType = animationType,
)
Box(
modifier = Modifier.graphicsLayer { alpha = contentAlpha }
) {
content()
}
}
}
}
private val AnimationType.animationSpec: DurationBasedAnimationSpec<Float>
get() = when (this) {
AnimationType.Bounce,
AnimationType.Fade -> tween(durationMillis = animationDuration)
AnimationType.LazyBounce -> keyframes {
durationMillis = animationDuration
initialValue at 0
0f at animationDuration / 4
targetValue / 2f at animationDuration / 2
targetValue / 2f at animationDuration
}
}
private val AnimationType.animationDuration: Int
get() = when (this) {
AnimationType.Bounce,
AnimationType.LazyBounce -> BounceAnimationDurationMillis
AnimationType.Fade -> FadeAnimationDurationMillis
}
private val AnimationType.animationDelay: Int
get() = animationDuration / NumIndicators
private val AnimationType.initialValue: Float
get() = when (this) {
AnimationType.Bounce -> IndicatorSize / 2f
AnimationType.LazyBounce -> -IndicatorSize / 2f
AnimationType.Fade -> 1f
}
private val AnimationType.targetValue: Float
get() = when (this) {
AnimationType.Bounce -> -IndicatorSize / 2f
AnimationType.LazyBounce -> IndicatorSize / 2f
AnimationType.Fade -> .2f
}
@Stable
interface LoadingIndicatorState {
operator fun get(index: Int): Float
fun start(animationType: AnimationType, scope: CoroutineScope)
}
class LoadingIndicatorStateImpl : LoadingIndicatorState {
private val animatedValues = List(NumIndicators) { mutableStateOf(0f) }
override fun get(index: Int): Float = animatedValues[index].value
override fun start(animationType: AnimationType, scope: CoroutineScope) {
repeat(NumIndicators) { index ->
scope.launch {
animate(
initialValue = animationType.initialValue,
targetValue = animationType.targetValue,
animationSpec = infiniteRepeatable(
animation = animationType.animationSpec,
repeatMode = RepeatMode.Reverse,
initialStartOffset = StartOffset(animationType.animationDelay * index)
),
) { value, _ -> animatedValues[index].value = value }
}
}
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as LoadingIndicatorStateImpl
if (animatedValues != other.animatedValues) return false
return true
}
override fun hashCode(): Int {
return animatedValues.hashCode()
}
}
@Composable
fun rememberLoadingIndicatorState(
animating: Boolean,
animationType: AnimationType,
): LoadingIndicatorState {
val state = remember {
LoadingIndicatorStateImpl()
}
LaunchedEffect(key1 = Unit) {
if (animating) {
state.start(animationType, this)
}
}
return state
}
@Composable
private fun LoadingIndicator(
animating: Boolean,
modifier: Modifier = Modifier,
color: Color = MaterialTheme.colors.primary,
indicatorSpacing: Dp = MarginHalf,
animationType: AnimationType,
) {
val state = rememberLoadingIndicatorState(animating, animationType)
Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
repeat(NumIndicators) { index ->
LoadingDot(
modifier = Modifier
.padding(horizontal = indicatorSpacing)
.width(IndicatorSize.dp)
.aspectRatio(1f)
.then(
when (animationType) {
AnimationType.Bounce,
AnimationType.LazyBounce -> Modifier.offset(
y = state[index].coerceAtMost(
IndicatorSize / 2f
).dp
)
AnimationType.Fade -> Modifier.graphicsLayer { alpha = state[index] }
}
),
color = color,
)
}
}
}
@Composable
private fun LoadingDot(
color: Color,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.clip(shape = CircleShape)
.background(color = color)
)
}
@Preview(widthDp = 360, heightDp = 360)
@Composable
private fun PreviewLoadingButton() {
LoadingButtonTheme {
var loading by remember {
mutableStateOf(false)
}
Surface(modifier = Modifier.fillMaxSize()) {
LoadingButton(
onClick = { loading = !loading },
modifier = Modifier
.fillMaxWidth()
.padding(all = 16.dp),
loading = loading,
) {
Text(
text = "Refresh"
)
}
}
}
}
@Preview(widthDp = 200, heightDp = 200)
@Composable
fun IconPreview() {
LoadingButtonTheme {
Surface(modifier = Modifier.fillMaxSize()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Default.Add,
modifier = Modifier
.width(100.dp)
.aspectRatio(1f),
contentDescription = null,
)
}
}
}
}
@ptmz-ivy
Copy link

ptmz-ivy commented Aug 11, 2022

Thanks for your sharing, I'm trying to use this code locally, but the animation doesn't work.
Maybe those codes need to be changed as following:

@Composable
fun rememberLoadingIndicatorState(
    animating: Boolean,
    animationType: AnimationType,
): LoadingIndicatorState {
    val state = remember {
        LoadingIndicatorStateImpl()
    }
    LaunchedEffect(key1 = animating) { // key1 should to be 'animating'
        if (animating) {
            state.start(animationType, this)
        }
    }
    return state
}

Hope you have a nice day : )

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