如何为Jetpack Compose的HorizontalPager创建带动画的点指示器?
Got it, let's build that custom animated dot indicator you're craving—since third-party libraries aren't hitting the mark, we'll roll our own using Compose's built-in animation APIs. I'll show you two common animated styles you can adapt to your exact needs.
Option 1: Scaling & Color Transition Dots
This style makes each dot scale up and shift color when selected, with smooth transitions during swipes to feel responsive.
Step 1: Create the Indicator Composable
import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.animateColorAsState import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.google.accompanist.pager.PagerState @Composable fun AnimatedScalingDotIndicator( pagerState: PagerState, dotCount: Int, selectedColor: Color = Color(0xFF6200EE), unselectedColor: Color = Color(0xFFE0E0E0), dotSize: Dp = 8.dp, selectedDotSize: Dp = 16.dp, spacing: Dp = 12.dp ) { Row( horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth() ) { // Track current page and swipe offset state val currentPage by remember { derivedStateOf { pagerState.currentPage } } val pageOffset by remember { derivedStateOf { pagerState.currentPageOffsetFraction } } repeat(dotCount) { index -> val isSelected = index == currentPage // Animate dot size between selected/unselected states val scale by animateDpAsState( targetValue = if (isSelected) selectedDotSize else dotSize, animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing) ) // Animate dot color transition val dotColor by animateColorAsState( targetValue = if (isSelected) selectedColor else unselectedColor, animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing) ) // Add horizontal offset for smooth swipe transitions between pages val swipeOffset = when { index == currentPage + 1 && pageOffset > 0 -> pageOffset * (dotSize + spacing) index == currentPage && pageOffset < 0 -> pageOffset * (dotSize + spacing) else -> 0f } Box( modifier = Modifier .padding(horizontal = spacing / 2) .size(scale) .offset(x = swipeOffset.dp) .background(color = dotColor, shape = CircleShape) ) } } }
Step 2: Use It With HorizontalPager
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.Text import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.unit.sp import com.google.accompanist.pager.HorizontalPager import com.google.accompanist.pager.rememberPagerState @Composable fun PagerWithScalingIndicator() { val pageCount = 3 val pagerState = rememberPagerState(pageCount = { pageCount }) HorizontalPager(state = pagerState) { page -> // Your page content goes here Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { Text(text = "Page $page", fontSize = 24.sp) } } // Attach the indicator below the pager AnimatedScalingDotIndicator( pagerState = pagerState, dotCount = pageCount, selectedColor = Color(0xFF6200EE), unselectedColor = Color(0xFFE0E0E0), dotSize = 8.dp, selectedDotSize = 16.dp, spacing = 12.dp ) }
Option 2: Sliding Selected Dot
This style uses a fixed set of unselected dots, with a single highlighted dot that slides smoothly between positions as you swipe pages.
The Composable Code
import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.google.accompanist.pager.PagerState @Composable fun SlidingDotIndicator( pagerState: PagerState, dotCount: Int, selectedColor: Color = Color(0xFF6200EE), unselectedColor: Color = Color(0xFFE0E0E0), dotSize: Dp = 8.dp, spacing: Dp = 12.dp ) { val indicatorWidth = dotSize + spacing // Animate the sliding offset of the selected dot val slideOffset by animateDpAsState( targetValue = indicatorWidth * pagerState.currentPage + (indicatorWidth * pagerState.currentPageOffsetFraction), animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing) ) Row( horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically ) { // Render static unselected background dots repeat(dotCount) { Box( modifier = Modifier .padding(horizontal = spacing / 2) .size(dotSize) .background(color = unselectedColor, shape = CircleShape) ) } // Render the sliding selected dot (positioned above background dots) Box( modifier = Modifier .size(dotSize) .offset(x = slideOffset - (dotSize / 2) - (spacing / 2)) .background(color = selectedColor, shape = CircleShape) .align(Alignment.CenterVertically) ) } }
Customization Tips
- Adjust Animation Speed: Tweak the
durationMillisvalue intween()to make transitions faster or slower. - Easing Curves: Swap
FastOutSlowInEasingwithLinearEasingorFastOutLinearInEasingfor different motion feels. - Add Extra Flair: For a pulse effect on the selected dot, use
animateFloatAsStatewithinfiniteRepeatableto scale it up and down continuously. - Non-Circular Dots: Replace
CircleShapewithRoundedCornerShapeor custom shapes for square/oval indicators.
Just make sure your dotCount matches the page count of your HorizontalPager—you can tie it directly to the pageCount lambda in rememberPagerState to avoid mismatches.
内容的提问来源于stack exchange,提问作者CookieMonster

