Instantly share code, notes, and snippets.
Last active
November 24, 2023 17:53
-
Star
(1)
1
You must be signed in to star a gist -
Fork
(1)
1
You must be signed in to fork a gist
-
Save fvilarino/7dc026b8a590ef65744180b984587ede to your computer and use it in GitHub Desktop.
Loading Button Final
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | |
) | |
} | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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:
Hope you have a nice day : )