Created
August 18, 2025 07:49
-
-
Save kibotu/f85389b517edc93ba36d7ef81f4b4dd4 to your computer and use it in GitHub Desktop.
This file contains hidden or 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
import androidx.compose.animation.core.CubicBezierEasing | |
import androidx.compose.animation.core.LinearEasing | |
import androidx.compose.animation.core.RepeatMode | |
import androidx.compose.animation.core.animateFloat | |
import androidx.compose.animation.core.animateFloatAsState | |
import androidx.compose.animation.core.infiniteRepeatable | |
import androidx.compose.animation.core.rememberInfiniteTransition | |
import androidx.compose.animation.core.tween | |
import androidx.compose.foundation.background | |
import androidx.compose.foundation.layout.Box | |
import androidx.compose.foundation.layout.fillMaxSize | |
import androidx.compose.foundation.layout.size | |
import androidx.compose.foundation.shape.CircleShape | |
import androidx.compose.material3.MaterialTheme | |
import androidx.compose.runtime.Composable | |
import androidx.compose.runtime.LaunchedEffect | |
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.graphics.graphicsLayer | |
import androidx.compose.ui.platform.LocalConfiguration | |
import androidx.compose.ui.tooling.preview.Preview | |
import androidx.compose.ui.unit.dp | |
import kotlin.math.sqrt | |
import kotlin.time.Duration | |
import kotlin.time.Duration.Companion.milliseconds | |
import kotlin.time.DurationUnit | |
/** | |
* A heartbeat animation composable that displays ripple effects with an optional exit animation. | |
* | |
* Matches the iOS implementation timing and behavior for consistent cross-platform animation. | |
* | |
* @param modifier Modifier to be applied to the animation container | |
* @param isVisible Whether the animation should be visible and running | |
* @param exitAnimationDuration Duration for the exit animation | |
* @param onStartExitAnimation Callback invoked when the exit animation starts | |
*/ | |
@Composable | |
fun HeartbeatAnimation( | |
modifier: Modifier = Modifier, | |
isVisible: Boolean = true, | |
exitAnimationDuration: Duration = Duration.ZERO, | |
onStartExitAnimation: () -> Unit = {} | |
) { | |
// Animation constants - adjusted for larger scale | |
val rippleCount = 4 | |
val totalAnimationDuration = 5000 // Increased from 3313ms for better visual speed with larger ripples | |
val rippleDuration = (totalAnimationDuration * 2) / 3 // 3333ms | |
val rippleDelay = rippleDuration / 8 // 417ms between ripples | |
val baseSize = 144.dp | |
val containerSize = 288.dp | |
// Track exit animation state | |
var isExitAnimationStarted by remember { mutableStateOf(false) } | |
var startAnimation by remember { mutableStateOf(isVisible) } | |
// Trigger exit animation when visibility changes | |
LaunchedEffect(isVisible) { | |
startAnimation = startAnimation || isVisible | |
if (!isVisible && !isExitAnimationStarted) { | |
isExitAnimationStarted = true | |
onStartExitAnimation() | |
} | |
} | |
// Calculate screen dimensions for proper scaling | |
val configuration = LocalConfiguration.current | |
val screenWidth = configuration.screenWidthDp | |
val screenHeight = configuration.screenHeightDp | |
val screenDiagonal = sqrt((screenWidth * screenWidth + screenHeight * screenHeight).toFloat()) | |
// Calculate target ripple scale to reach ~1/3 over screen width | |
val targetRippleWidth = screenWidth * 1.33f // 1/3 over screen width | |
val maxRippleScale = targetRippleWidth / baseSize.value | |
// Exit animation scale with snappy easing | |
val snappyEasing = CubicBezierEasing(0.2f, 0.0f, 0.2f, 1.0f) | |
val exitAnimationScale by animateFloatAsState( | |
targetValue = if (isExitAnimationStarted) screenDiagonal / baseSize.value else 0f, | |
animationSpec = tween( | |
durationMillis = exitAnimationDuration.toInt(DurationUnit.MILLISECONDS), | |
easing = snappyEasing | |
), | |
label = "exitScale" | |
) | |
// Infinite ripple animation transition | |
val infiniteTransition = rememberInfiniteTransition(label = "heartbeatTransition") | |
Box( | |
modifier = modifier.fillMaxSize(), | |
contentAlignment = Alignment.Center | |
) { | |
// Only show ripples when visible and not exiting | |
if (startAnimation) { | |
Box( | |
modifier = Modifier.size(containerSize), | |
contentAlignment = Alignment.Center | |
) { | |
// Create ripple circles with staggered animations | |
repeat(rippleCount) { index -> | |
RippleCircle( | |
infiniteTransition = infiniteTransition, | |
index = index, | |
rippleDuration = rippleDuration, | |
rippleDelay = rippleDelay, | |
totalCycleDuration = totalAnimationDuration, | |
baseSize = baseSize, | |
maxScale = maxRippleScale | |
) | |
} | |
} | |
} | |
// Exit animation circle | |
if (isExitAnimationStarted) { | |
Box( | |
modifier = Modifier | |
.size(baseSize) | |
.graphicsLayer { | |
scaleX = exitAnimationScale | |
scaleY = exitAnimationScale | |
} | |
.background( | |
color = MaterialTheme.colorScheme.blueCatalina, | |
shape = CircleShape | |
) | |
) | |
} | |
} | |
} | |
/** | |
* Individual ripple circle component with staggered animation. | |
* | |
* Uses a time-based approach that's more resilient to frame drops and matches iOS behavior. | |
* | |
* Performance optimizations: | |
* - Uses LinearEasing for time progression to avoid compound easing calculations | |
* - Applies custom easing only to the final transform step | |
* - Single animation drives both scale and alpha for better synchronization | |
* - Optimized for variable frame rates during fragment transactions | |
*/ | |
@Composable | |
private fun RippleCircle( | |
infiniteTransition: androidx.compose.animation.core.InfiniteTransition, | |
index: Int, | |
rippleDuration: Int, | |
rippleDelay: Int, | |
totalCycleDuration: Int, | |
baseSize: androidx.compose.ui.unit.Dp, | |
maxScale: Float | |
) { | |
// Use easing that matches iOS custom animation (0.4, 0.0, 0.2, 1.0) | |
val customEasing = CubicBezierEasing(0.4f, 0.0f, 0.2f, 1.0f) | |
// Calculate the individual ripple delay for this circle | |
val individualDelay = rippleDelay * index | |
// Use linear time progression for better frame-rate independence | |
val linearTimeProgress by infiniteTransition.animateFloat( | |
initialValue = 0f, | |
targetValue = 1f, | |
animationSpec = infiniteRepeatable( | |
animation = tween( | |
durationMillis = totalCycleDuration, | |
delayMillis = individualDelay, | |
easing = LinearEasing // Linear for time, apply easing later | |
), | |
repeatMode = RepeatMode.Restart | |
), | |
label = "rippleTime$index" | |
) | |
// Calculate the progress within the ripple's active duration | |
val rippleActiveRatio = rippleDuration.toFloat() / totalCycleDuration | |
val rawProgress = when { | |
linearTimeProgress <= 0f -> 0f | |
linearTimeProgress >= rippleActiveRatio -> 1f | |
else -> linearTimeProgress / rippleActiveRatio | |
} | |
// Apply custom easing to the raw progress for smooth animation | |
val easedProgress = customEasing.transform(rawProgress) | |
// Scale: from 1.0 to maxScale (calculated to reach ~1/3 over screen width) | |
val scale = 1f + (easedProgress * (maxScale - 1f)) | |
// Alpha: from 0.25 to 0.0 (matching iOS) | |
val alpha = 0.25f * (1f - easedProgress) | |
Box( | |
modifier = Modifier | |
.size(baseSize) | |
.graphicsLayer { | |
scaleX = scale | |
scaleY = scale | |
this.alpha = alpha | |
} | |
.background( | |
color = MaterialTheme.colorScheme.blueCatalina, | |
shape = CircleShape | |
) | |
) | |
} | |
@Preview(showBackground = true) | |
@Composable | |
fun HeartBeatAnimationPreview() { | |
AppTheme { | |
HeartbeatAnimation( | |
isVisible = true, | |
exitAnimationDuration = 600L.milliseconds, | |
onStartExitAnimation = { } | |
) | |
} | |
} | |
@Preview(showBackground = true) | |
@Composable | |
fun RippleCirclePreview() { | |
AppTheme { | |
RippleCircle( | |
infiniteTransition = rememberInfiniteTransition(label = "heartbeatTransition"), | |
index = 0, | |
rippleDuration = (5000 * 2) / 3, | |
rippleDelay = ((5000 * 2) / 3) / 8, | |
totalCycleDuration = 5000, | |
baseSize = 144.dp, | |
maxScale = 4.0f // Default scale for preview | |
) | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment