feat: Implement gooey pager indicator with animated transitions

This commit is contained in:
k1ngsterr1
2026-02-09 09:22:27 +05:00
parent fa785ddb29
commit e1c119f621

View File

@@ -28,6 +28,7 @@ import androidx.compose.ui.draw.scale
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned 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.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import com.airbnb.lottie.compose.* import com.airbnb.lottie.compose.*
import com.rosetta.messenger.R import com.rosetta.messenger.R
import com.rosetta.messenger.ui.theme.* 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.absoluteValue
import kotlin.math.atan2
import kotlin.math.ceil
import kotlin.math.cos
import kotlin.math.floor
import kotlin.math.hypot import kotlin.math.hypot
import kotlin.math.min
import kotlin.math.sin
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
// App colors (matching React Native) // App colors (matching React Native)
@@ -302,9 +313,9 @@ fun OnboardingScreen(
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
// Page indicators // Page indicators
PagerIndicator( GooeyPagerIndicator(
pageCount = onboardingPages.size, pageCount = onboardingPages.size,
currentPage = pagerState.currentPage, pagerState = pagerState,
selectedColor = PrimaryBlue, selectedColor = PrimaryBlue,
unselectedColor = indicatorColor unselectedColor = indicatorColor
) )
@@ -665,41 +676,142 @@ fun OnboardingPageContent(
} }
@Composable @Composable
fun PagerIndicator( @OptIn(ExperimentalFoundationApi::class)
fun GooeyPagerIndicator(
pageCount: Int, pageCount: Int,
currentPage: Int, pagerState: PagerState,
selectedColor: Color, selectedColor: Color,
unselectedColor: Color, unselectedColor: Color,
modifier: Modifier = Modifier modifier: Modifier = Modifier,
dotRadius: Dp = 2.8.dp,
dotSpacing: Dp = 12.dp,
indicatorHeight: Dp = 18.dp
) { ) {
Row( if (pageCount <= 0) return
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"
)
Box( val indicatorWidth =
modifier = if (pageCount > 1) dotSpacing * (pageCount - 1) + dotRadius * 8 else dotRadius * 8
Modifier.height(8.dp)
.width(width) Canvas(modifier = modifier.width(indicatorWidth).height(indicatorHeight)) {
.clip(CircleShape) val baseRadius = dotRadius.toPx()
.background( val spacing = dotSpacing.toPx()
if (isSelected) selectedColor val centerY = size.height / 2f
else unselectedColor 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 @Composable
fun StartMessagingButton(onClick: () -> Unit, modifier: Modifier = Modifier) { fun StartMessagingButton(onClick: () -> Unit, modifier: Modifier = Modifier) {
// Shining effect animation // Shining effect animation