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.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
|
||||||
|
|||||||
Reference in New Issue
Block a user