Skip to content

Instantly share code, notes, and snippets.

@kibotu
Created August 18, 2025 07:49
Show Gist options
  • Save kibotu/f85389b517edc93ba36d7ef81f4b4dd4 to your computer and use it in GitHub Desktop.
Save kibotu/f85389b517edc93ba36d7ef81f4b4dd4 to your computer and use it in GitHub Desktop.
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