You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何为Jetpack Compose的HorizontalPager创建带动画的点指示器?

Custom Animated Dot Indicator for 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 durationMillis value in tween() to make transitions faster or slower.
  • Easing Curves: Swap FastOutSlowInEasing with LinearEasing or FastOutLinearInEasing for different motion feels.
  • Add Extra Flair: For a pulse effect on the selected dot, use animateFloatAsState with infiniteRepeatable to scale it up and down continuously.
  • Non-Circular Dots: Replace CircleShape with RoundedCornerShape or 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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.04.27 18:13:09