[Android] 인스타그램 Dot Indicator 만들기

uuranus·2024년 8월 8일
0
post-thumbnail

기존 애니메이션 분석하기

기존 애니메이션

  1. 가장 크기가 큰 점 3개는 항상 중앙에 위치한다.
  2. 이 세개 점 사이에서 이동할 때는 애니메이션 변화 없음.
  3. 크기가 작은 점의 위치로 이동할 때만 변화

또한 전체 페이지가 6개 이상이면 초반에도 5개 점 중 마지막 2개의 점은 작은 사이즈를 가지지만

페이지 5개 이하

페이지가 5개 이하일 때는 모든 점이 다 큰 사이즈를 유지한다.

세 개의 점 기억하기

우선 현재 페이지가 바뀔 때마다 변하는 것이 아니기 때문에 애니메이션이 변경되는 가운데 세 개의 점의 인덱스를 가지고 있고 그 양옆으로 작은 크기의 점까지의 인덱스도 추가로 가지고 있어서 작은 점을 보여줄 지 말지 결정할 수 있도록 하였다.

var fullDotLeft by remember {
    mutableIntStateOf(0)
}

var fullDotRight by remember {
    mutableIntStateOf(
        if (totalPage <= 5) {
            5
        } else {
            fullDotLeft + 2
        }
    )
}

var left by remember {
    mutableIntStateOf(0)
}

var right by remember {
    mutableIntStateOf(minOf(totalPage, 5))
}

위치 값 변경하기

LaunchedEffect(currentPage) {
    if (currentPage > fullDotRight) {
        fullDotRight++
        fullDotLeft++

        if (fullDotLeft - left > 2) {
            left++
        }
        right = minOf(totalPage - 1, right + 1)
    }

    if (currentPage < fullDotLeft) {
        fullDotRight--
        fullDotLeft--

        if (right - fullDotRight > 2) {
            right--
        }
        left = maxOf(0, left - 1)
    }
}

currentPage가 fullDotLeft..fullDotRight의 범위를 벗어나는 순간에 위치 정보를 갱신하여 애니메이션이 적용되도록 하였다.

scale 애니메이션 넣기

val dotScales = List(totalPage) { index ->
    val targetValue = when {
        index in fullDotLeft..fullDotRight -> 1f
        index == fullDotLeft - 1 && index >= left -> 0.7f
        index == fullDotRight + 1 && index <= right -> 0.7f
        index == fullDotLeft - 2 && index >= left -> 0.4f
        index == fullDotRight + 2 && index <= right -> 0.4f
        else -> 0f
    }
    animateFloatAsState(
        targetValue = targetValue,
        animationSpec = tween(
            durationMillis = animationDuration,
            easing = LinearEasing
        ),
        label = "dotScale"
    ).value
}

위치 인덱스값을 기준으로 크기를 설정해주었다.
left..right 의 범위를 벗어나는 경우는 0f를 설정해주었는데 이는 항상 큰 세 개의 점이 가운데에 위치하도록 빈공간으로 자리를 차지하게 하기 위함이다.

AnimatableVisibility

  • 현재 위치 범위에 더 이상 아니게 된 Dot은 사라지고 위치 범위에 들어오게 되면 새롭게 생성된다.
  • 근데 실제 애니메이션처럼 기존 점들이 왼쪽이나 오른쪽으로 슬라이딩되면서 사라지고 생겨야 하기에
  • ShrinkHorizontally, ExpandHorizontally를 사용했다.
AnimatedVisibility(
    visible = index in left..right,
    enter = expandHorizontally(
        animationSpec = tween(
            durationMillis = animationDuration,
            easing = FastOutSlowInEasing
        )
    ),
    exit = shrinkHorizontally(
        animationSpec = tween(
            durationMillis = animationDuration,
            easing = FastOutSlowInEasing
        )
    )
) {
    Box(
        modifier = Modifier
            .width(dotSize)
            .aspectRatio(1f)
            .scale(dotScales[index])
            .clip(CircleShape)
            .background(
                if (index == currentPage) {
                    Color(0xFF0096FB)
                } else {
                    Color(0xFFDADFE3)
                }
            )
    )
}

빈 공간 채우기

repeat(2) {
    AnimatedVisibility(
        visible = right - fullDotRight < 2,
        enter = expandHorizontally(
            animationSpec = tween(
                durationMillis = animationDuration,
                easing = FastOutSlowInEasing
            )
        ),
        exit = shrinkHorizontally(
            animationSpec = tween(
                durationMillis = animationDuration,
                easing = FastOutSlowInEasing
            )
        )
    ) {
        Row {
            Box(
                modifier = Modifier
                    .width(dotSize)
                    .aspectRatio(1f)
                    .scale(0f)
                    .clip(CircleShape)
            )
        }
    }
}

다음과 같이 뷰의 크기는 존재하지만 scale은 0f로 하여서 화면에는 보이지 않도록 하였다.

최종 결과

전체 코드

@Composable
fun InstagramDotIndicator(
    modifier: Modifier = Modifier,
    currentPage: Int,
    totalPage: Int,
    spacePadding: Dp,
) {
    require(totalPage > 0) {
        "At least 1 page is required"
    }

    require(currentPage in 0..<totalPage) {
        "currentPage is out of totalPage bounds"
    }

    var width by remember {
        mutableIntStateOf(0)
    }
    var height by remember {
        mutableIntStateOf(0)
    }

    val maxPageDots = 7

    val density = LocalDensity.current
    val dotSize by remember {
        derivedStateOf {
            with(density) {
                minOf(
                    ((width - spacePadding.toPx() * (maxPageDots - 1)) / maxPageDots).toDp(),
                    height.toDp()
                )
            }
        }
    }

    var fullDotLeft by remember {
        mutableIntStateOf(0)
    }
    var fullDotRight by remember {
        mutableIntStateOf(
            if (totalPage <= 5) {
                5
            } else {
                fullDotLeft + 2
            }
        )
    }

    var left by remember {
        mutableIntStateOf(0)
    }

    var right by remember {
        mutableIntStateOf(minOf(totalPage, 5))
    }

    LaunchedEffect(currentPage) {
        if (currentPage > fullDotRight) {
            fullDotRight++
            fullDotLeft++

            if (fullDotLeft - left > 2) {
                left++
            }
            right = minOf(totalPage - 1, right + 1)
        }

        if (currentPage < fullDotLeft) {
            fullDotRight--
            fullDotLeft--

            if (right - fullDotRight > 2) {
                right--
            }
            left = maxOf(0, left - 1)
        }
    }

    val animationDuration = 100

    val dotScales = List(totalPage) { index ->
        val targetValue = when {
            index in fullDotLeft..fullDotRight -> 1f
            index == fullDotLeft - 1 && index >= left -> 0.7f
            index == fullDotRight + 1 && index <= right -> 0.7f
            index == fullDotLeft - 2 && index >= left -> 0.4f
            index == fullDotRight + 2 && index <= right -> 0.4f
            else -> 0f
        }
        animateFloatAsState(
            targetValue = targetValue,
            animationSpec = tween(durationMillis = animationDuration, easing = LinearEasing),
            label = "dotScale"
        ).value
    }

    Box(
        modifier = modifier.onGloballyPositioned {
            width = it.size.width
            height = it.size.height
        },
        contentAlignment = Alignment.Center
    ) {
        Row(
            horizontalArrangement = Arrangement.spacedBy(spacePadding),
            verticalAlignment = Alignment.CenterVertically
        ) {

            repeat(2) {
                AnimatedVisibility(
                    visible = fullDotLeft - left < 2,
                    enter = expandHorizontally(
                        animationSpec = tween(
                            durationMillis = animationDuration,
                            easing = FastOutSlowInEasing
                        )
                    ),
                    exit = shrinkHorizontally(
                        animationSpec = tween(
                            durationMillis = animationDuration,
                            easing = FastOutSlowInEasing
                        )
                    )
                ) {
                    Row {
                        Box(
                            modifier = Modifier
                                .width(dotSize)
                                .aspectRatio(1f)
                                .scale(0f)
                                .clip(CircleShape)
                        )
                    }

                }

            }

            repeat(totalPage) { index ->

                AnimatedVisibility(
                    visible = index in left..right,
                    enter = expandHorizontally(
                        animationSpec = tween(
                            durationMillis = animationDuration,
                            easing = FastOutSlowInEasing
                        )
                    ),
                    exit = shrinkHorizontally(
                        animationSpec = tween(
                            durationMillis = animationDuration,
                            easing = FastOutSlowInEasing
                        )
                    )
                ) {
                    Box(
                        modifier = Modifier
                            .width(dotSize)
                            .aspectRatio(1f)
                            .scale(dotScales[index])
                            .clip(CircleShape)
                            .background(
                                if (index == currentPage) {
                                    Color(0xFF0096FB)
                                } else {
                                    Color(0xFFDADFE3)
                                }
                            )
                    )
                }
            }

            repeat(2) {
                AnimatedVisibility(
                    visible = right - fullDotRight < 2,
                    enter = expandHorizontally(
                        animationSpec = tween(
                            durationMillis = animationDuration,
                            easing = FastOutSlowInEasing
                        )
                    ),
                    exit = shrinkHorizontally(
                        animationSpec = tween(
                            durationMillis = animationDuration,
                            easing = FastOutSlowInEasing
                        )
                    )
                ) {
                    Row {
                        Box(
                            modifier = Modifier
                                .width(dotSize)
                                .aspectRatio(1f)
                                .scale(0f)
                                .clip(CircleShape)
                        )

                    }

                }

            }
        }
    }
}

깃헙 링크

https://github.com/uuranus/compose-animations

profile
Frontend Developer

0개의 댓글