From e1c119f6210ae0348b135f6023d864d0544918af Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Mon, 9 Feb 2026 09:22:27 +0500 Subject: [PATCH] feat: Implement gooey pager indicator with animated transitions --- .../ui/onboarding/OnboardingScreen.kt | 168 +++++++++++++++--- 1 file changed, 140 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/ui/onboarding/OnboardingScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/onboarding/OnboardingScreen.kt index 6628b1a..9d29ce7 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/onboarding/OnboardingScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/onboarding/OnboardingScreen.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.draw.scale import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.onGloballyPositioned @@ -38,14 +39,24 @@ import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.view.WindowCompat import com.airbnb.lottie.compose.* import com.rosetta.messenger.R import com.rosetta.messenger.ui.theme.* +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.acos import kotlin.math.absoluteValue +import kotlin.math.atan2 +import kotlin.math.ceil +import kotlin.math.cos +import kotlin.math.floor import kotlin.math.hypot +import kotlin.math.min +import kotlin.math.sin import kotlinx.coroutines.delay // App colors (matching React Native) @@ -302,9 +313,9 @@ fun OnboardingScreen( Spacer(modifier = Modifier.height(24.dp)) // Page indicators - PagerIndicator( + GooeyPagerIndicator( pageCount = onboardingPages.size, - currentPage = pagerState.currentPage, + pagerState = pagerState, selectedColor = PrimaryBlue, unselectedColor = indicatorColor ) @@ -665,41 +676,142 @@ fun OnboardingPageContent( } @Composable -fun PagerIndicator( +@OptIn(ExperimentalFoundationApi::class) +fun GooeyPagerIndicator( pageCount: Int, - currentPage: Int, + pagerState: PagerState, selectedColor: Color, unselectedColor: Color, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + dotRadius: Dp = 2.8.dp, + dotSpacing: Dp = 12.dp, + indicatorHeight: Dp = 18.dp ) { - Row( - modifier = modifier, - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - repeat(pageCount) { index -> - val isSelected = index == currentPage - val width by - animateDpAsState( - targetValue = if (isSelected) 20.dp else 8.dp, - animationSpec = spring(dampingRatio = 0.8f), - label = "indicatorWidth" - ) + if (pageCount <= 0) return - Box( - modifier = - Modifier.height(8.dp) - .width(width) - .clip(CircleShape) - .background( - if (isSelected) selectedColor - else unselectedColor - ) - ) + val indicatorWidth = + if (pageCount > 1) dotSpacing * (pageCount - 1) + dotRadius * 8 else dotRadius * 8 + + Canvas(modifier = modifier.width(indicatorWidth).height(indicatorHeight)) { + val baseRadius = dotRadius.toPx() + val spacing = dotSpacing.toPx() + val centerY = size.height / 2f + val trackWidth = if (pageCount > 1) spacing * (pageCount - 1) else 0f + val startX = (size.width - trackWidth) / 2f + + val rawPosition = + (pagerState.currentPage + pagerState.currentPageOffsetFraction) + .coerceIn(0f, (pageCount - 1).toFloat()) + val activeCenter = Offset(startX + rawPosition * spacing, centerY) + + repeat(pageCount) { index -> + val center = Offset(startX + index * spacing, centerY) + drawCircle(color = unselectedColor, radius = baseRadius, center = center) } + + val from = floor(rawPosition.toDouble()).toInt().coerceIn(0, pageCount - 1) + val to = ceil(rawPosition.toDouble()).toInt().coerceIn(0, pageCount - 1) + val transition = rawPosition - from + val stretch = (1f - abs(transition - 0.5f) * 2f).coerceIn(0f, 1f) + val activeRadius = baseRadius * (1.08f + stretch * 0.34f) + + if (from != to) { + val anchorIndex = if (transition < 0.5f) from else to + val anchorCenter = Offset(startX + anchorIndex * spacing, centerY) + val anchorRadius = baseRadius * (1.0f - stretch * 0.1f) + + createMetaballPath( + c1 = activeCenter, + r1 = activeRadius, + c2 = anchorCenter, + r2 = anchorRadius, + maxDistance = spacing * 1.28f, + viscosity = 0.32f, + handleSize = 2.25f + ) + ?.let { path -> + drawPath(path = path, color = selectedColor.copy(alpha = 0.92f)) + } + } + + drawCircle(color = selectedColor, radius = activeRadius, center = activeCenter) } } +private fun createMetaballPath( + c1: Offset, + r1: Float, + c2: Offset, + r2: Float, + maxDistance: Float, + viscosity: Float, + handleSize: Float +): Path? { + val dx = c2.x - c1.x + val dy = c2.y - c1.y + val d = hypot(dx, dy) + if (d <= 0.001f || d > maxDistance) return null + + val radiusDelta = abs(r1 - r2) + if (d <= radiusDelta) return null + + val u1: Double + val u2: Double + if (d < r1 + r2) { + val acos1Arg = ((r1 * r1 + d * d - r2 * r2) / (2f * r1 * d)).coerceIn(-1f, 1f) + val acos2Arg = ((r2 * r2 + d * d - r1 * r1) / (2f * r2 * d)).coerceIn(-1f, 1f) + u1 = acos(acos1Arg.toDouble()) + u2 = acos(acos2Arg.toDouble()) + } else { + u1 = 0.0 + u2 = 0.0 + } + + val angleBetweenCenters = atan2(dy, dx).toDouble() + val maxSpreadArg = ((r1 - r2) / d).coerceIn(-1f, 1f) + val maxSpread = acos(maxSpreadArg.toDouble()) + val v = viscosity.coerceIn(0f, 1f).toDouble() + + val angle1 = angleBetweenCenters + u1 + (maxSpread - u1) * v + val angle2 = angleBetweenCenters - u1 - (maxSpread - u1) * v + val angle3 = angleBetweenCenters + PI - u2 - (PI - u2 - maxSpread) * v + val angle4 = angleBetweenCenters - PI + u2 + (PI - u2 - maxSpread) * v + + val p1 = pointOnCircle(c1, angle1, r1) + val p2 = pointOnCircle(c1, angle2, r1) + val p3 = pointOnCircle(c2, angle3, r2) + val p4 = pointOnCircle(c2, angle4, r2) + + val totalRadius = r1 + r2 + val d2Base = min(viscosity * handleSize, distance(p1, p3) / totalRadius) + val d2 = d2Base * min(1f, (d * 2f) / totalRadius) + + val r1h = r1 * d2 + val r2h = r2 * d2 + + val h1 = pointOnCircle(p1, angle1 - PI / 2.0, r1h) + val h2 = pointOnCircle(p2, angle2 + PI / 2.0, r1h) + val h3 = pointOnCircle(p3, angle3 + PI / 2.0, r2h) + val h4 = pointOnCircle(p4, angle4 - PI / 2.0, r2h) + + return Path().apply { + moveTo(p1.x, p1.y) + cubicTo(h1.x, h1.y, h3.x, h3.y, p3.x, p3.y) + lineTo(p4.x, p4.y) + cubicTo(h4.x, h4.y, h2.x, h2.y, p2.x, p2.y) + close() + } +} + +private fun pointOnCircle(center: Offset, angle: Double, radius: Float): Offset { + return Offset( + x = (center.x + cos(angle) * radius).toFloat(), + y = (center.y + sin(angle) * radius).toFloat() + ) +} + +private fun distance(a: Offset, b: Offset): Float = hypot(a.x - b.x, a.y - b.y) + @Composable fun StartMessagingButton(onClick: () -> Unit, modifier: Modifier = Modifier) { // Shining effect animation