feat: Implement gooey pager indicator with animated transitions
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user