[Android] 인스타그램 PC 업로드 완료 애니메이션 만들기

uuranus·2024년 7월 27일
0
post-thumbnail

기존 애니메이션 분석하기

실제 인스타그램 애니메이션

PC 버전에서 인스타그램에 게시물을 올리면 나오는 애니메이션이다.

만들어야 하는 애니메이션은
1. 배경 원 - 완료가 될 때까지 무한히 회전해야함
2. 체크 표시 - 배경 원이 멈춘 후에 그려지며 창을 닫을 때까지 무한히 반복

이렇게 크게 2가지가 있다.

배경 원 만들기

우선 배경 링을 그려보자

그라데이션 적용하기

그라데이션 색상은 인스타 공식 홈페이지에서 가져왔다. 총 5개의 색상으로 이루어져 있고 사선으로 그라데이션이 이루어져 있기에

val colorList = listOf(
    Color(0xFFFFD600),
    Color(0xFFFF7A00),
    Color(0xFFFF0069),
    Color(0xFFD300C5),
    Color(0xFF7638FA)
)

Box(
    modifier = Modifier
        .fillMaxWidth()
        .aspectRatio(1f)
        .align(Alignment.Center)
        .background(
            brush = Brush.linearGradient(colorList),
            shape = RingShape(ringWidth = 12.dp)
        )
)

다음과 같이 linearGradient로 설정해줬다.
그라데이션 옵션은 여기에서 확인할 수 있다.

체크 마크 만들기

체크 마크는 Path를 이용해서 그렸다.

체크 마크의 비율을 확인해보니 왼쪽 체크마크는 높이의 절반에서 시작하고 꺽이는 지점은 가로길이의 1/3 지점 정도인 것 같다.

전체 높이는 width의 2/3 크기로 가로세로 비율이 1.5:1이 되도록 하였다.

val height = (size.width * 2 / 3).toInt()
val width = size.width

val centerY = size.height / 2

val curvePoint = width / 3
val startY = centerY - height / 2
val endY = centerY + height / 2

val path = Path().apply {
    moveTo(0f, centerY.toFloat())
    lineTo(curvePoint.toFloat(), endY.toFloat())
    lineTo(size.width.toFloat(), startY.toFloat())
}

배경 돌리기

Animatable API를 이용해서 회전시켰으며 isComplete가 될 때까지는 계속 반복해서 돌아야 하기 때문에 비동기적으로 계속 돌리도록 하였다.

val rotationAnimation = remember { Animatable(0f) }
val rotation by remember { derivedStateOf { rotationAnimation.value % 360f } }

val scope = rememberCoroutineScope()

val rotateDuration = 500
    
LaunchedEffect(isComplete) {
    scope.launch {
        while (true) {
            rotationAnimation.animateTo(
                targetValue = rotationAnimation.value + 360f,
                animationSpec = tween(
                    durationMillis = rotateDuration,
                    easing = LinearEasing
                )
            )
        }
    }
}

rotation을 적용해줄 때 조심해야하는 게

Box(
    modifier = Modifier
        .fillMaxSize()
        .padding(32.dp)
        .graphicsLayer {
            rotationZ = rotation
        }
) {
	//ring
    //check mark
}

돌리려는 컴포저블의 밖의 컴포저블에 적용을 해줘야 내부 컴포저블들이 회전하게 된다.

한 바퀴가 완전히 돌 때까지 더 돌리기

단순히 isComplete가 되었다고 바로 stop을 해버리면

이렇게 체크마크도 같이 돌아간 채로 그려지게 된다.
그래서 isComplete가 되었어도 남아있는 각도만큼 추가로 회전하게 해주었다.

LaunchedEffect(isComplete) {
    if (isComplete) {
        while (rotationAnimation.isRunning) {
            val remainAngle = rotationAnimation.value + (360f - rotation)
            rotationAnimation.animateTo(
                targetValue = remainAngle,
                animationSpec = tween(
                    durationMillis = ((360f - rotation) * rotateDuration / 360f).toInt(),
                    easing = LinearEasing
                )
            )
        }
    } else {
        scope.launch {
            while (true) {
                rotationAnimation.animateTo(
                    targetValue = rotationAnimation.value + 360f,
                    animationSpec = tween(
                        durationMillis = rotateDuration,
                        easing = LinearEasing
                    )
                )
            }
        }
    }
}

360도를 rotateDuration 시간동안 회전했으니까 남아있는 각도만큼으로 시간을 조절해줘야 자연스럽게 회전이 이어진다.

체크 마크 그리는 애니메이션

이제 체크 마크를 그려보자
animationDuration 동안 순차적으로 자른? PathSegment를 반복해서 그려주는 방식으로 그린다.

val pathMeasure = PathMeasure().apply {
    setPath(path, forceClosed = false)
}

val pathSegment = Path()

pathMeasure.getSegment(
    startDistance = 0f,
    stopDistance = pathMeasure.length * progress,
    path = pathSegment,
    startWithMoveTo = true
)

path segment는 PathMeasure를 통해서 얻을 수 있고 pathSegement에다가 progress길이만큼 자른 path를 저장한다.

그리고 drawPath에다가 pathSegment를 전달해주면 된다.

drawPath(
    path = pathSegment,
    brush = Brush.linearGradient(colorList),
    style = Stroke(
        width = 12.dp.toPx(),
        cap = StrokeCap.Round,
        join = StrokeJoin.Round
    )
)

체크 마크 무한히 반복하기

성공적으로 완료된 후에도 사용자가 창을 닫지 않으면 체크 마크가 무한히 계속 반복해서 그려진다. infiniteRepeatable를 이용하여 계속 그려주도록 하였다.

val infiniteTransition = rememberInfiniteTransition(label = "")

val pathLength by infiniteTransition.animateFloat(
    initialValue = 0f,
    targetValue = 1f,
    animationSpec = infiniteRepeatable(
        animation = tween(
            durationMillis = 1000,
            easing = LinearEasing
        ),
        repeatMode = RepeatMode.Restart
    ),
    label = ""
)

딜레이 넣기

체크 마크를 그릴 때 배경 원의 회전이 멈추고 난 후, 체크 마크를 다시 그릴 때 약간의 딜레이가 있는 걸 볼 수 있다.

val infiniteTransition = rememberInfiniteTransition(label = "")

val pathLength by infiniteTransition.animateFloat(
    initialValue = 0f,
    targetValue = 1f,
    animationSpec = infiniteRepeatable(
        animation = keyframes {
            durationMillis = 2700
            0f at 0 with LinearEasing
            0.3f at 300 with LinearEasing
            0.3f at 400
            1f at 700 with LinearEasing
            1f at 2600
        },
        repeatMode = RepeatMode.Restart,
        initialStartOffset = StartOffset(2000)
    ),
    label = ""
)

처음 시작 딜레이는 initialStartOffset로 설정해주었고 keyframes를 이용해서 0.7초동안 그려주고 2초동안은 딜레이가 있도록 설정하였다.
기존 애니메이션을 잘 보면 중간에 꺽이는 부분에서 잠깐 멈춰서 체크하는 것처럼 보이게 해주었길래 중간에 0.1초 잠깐 딜레이를 넣어주었다.

최종 결과물

전체 코드

val colorList = listOf(
    Color(0xFFFFD600),
    Color(0xFFFF7A00),
    Color(0xFFFF0069),
    Color(0xFFD300C5),
    Color(0xFF7638FA)
)

@Composable
fun InstagramProgress(
    isComplete: Boolean,
) {
    val rotationAnimation = remember { Animatable(0f) }
    val rotation by remember { derivedStateOf { rotationAnimation.value % 360f } }

    val scope = rememberCoroutineScope()

    val rotateDuration = 500

    LaunchedEffect(isComplete) {
        if (isComplete) {
            while (rotationAnimation.isRunning) {
                val remainAngle = rotationAnimation.value + (360f - rotation)
                rotationAnimation.animateTo(
                    targetValue = remainAngle,
                    animationSpec = tween(
                        durationMillis = (360f - rotation).toInt() * (rotateDuration / 360f).toInt(),
                        easing = LinearEasing
                    )
                )
            }
        } else {
            scope.launch {
                while (true) {
                    rotationAnimation.animateTo(
                        targetValue = rotationAnimation.value + 360f,
                        animationSpec = tween(
                            durationMillis = rotateDuration,
                            easing = LinearEasing
                        )
                    )
                }
            }
        }

    }

    Box(
        modifier = Modifier
            .fillMaxSize()
            .padding(32.dp)
            .graphicsLayer {
                rotationZ = rotation
            }
    ) {
        Box(
            modifier = Modifier
                .fillMaxWidth()
                .aspectRatio(1f)
                .align(Alignment.Center)
                .background(
                    brush = Brush.linearGradient(colorList),
                    shape = RingShape(
                        ringWidth = 12.dp
                    )
                )
        )

        if (isComplete && rotationAnimation.isRunning.not()) {
            CheckMarkAnimation(
                modifier = Modifier
                    .fillMaxSize()
                    .padding(40.dp)
            )
        }

    }

}


@Composable
private fun CheckMarkAnimation(modifier: Modifier) {

    val infiniteTransition = rememberInfiniteTransition(label = "pathLength")
    val pathLength by infiniteTransition.animateFloat(
        initialValue = 0f,
        targetValue = 1f,
        animationSpec = infiniteRepeatable(
            animation = keyframes {
                durationMillis = 2700
                0f at 0 with LinearEasing
                0.3f at 300 with LinearEasing
                0.3f at 400
                1f at 700 with LinearEasing
                1f at 2600
            },
            repeatMode = RepeatMode.Restart,
            initialStartOffset = StartOffset(2000)
        ), label = "pathLength"
    )

    Surface(
        modifier = modifier.fillMaxSize(),
        color = Color.Transparent
    ) {
        Box(
            modifier = Modifier
                .fillMaxSize()
                .padding(32.dp),
            contentAlignment = Alignment.Center
        ) {
            Canvas(modifier = Modifier.fillMaxSize()) {
                drawCheckMark(size, pathLength)
            }
        }
    }
}

private fun DrawScope.drawCheckMark(
    size: Size, progress: Float,
) {
    val height = (size.width * 2 / 3).toInt()
    val width = size.width

    val centerY = size.height / 2

    val curvePoint = width / 3
    val startY = centerY - height / 2
    val endY = centerY + height / 2

    val path = Path().apply {
        moveTo(0f, centerY)
        lineTo(curvePoint, endY)
        lineTo(size.width, startY)
    }

    val pathMeasure = PathMeasure().apply {
        setPath(path, forceClosed = false)
    }

    val pathSegment = Path()

    pathMeasure.getSegment(
        startDistance = 0f,
        stopDistance = pathMeasure.length * progress,
        pathSegment,
        startWithMoveTo = true
    )

    drawPath(
        path = pathSegment,
        brush = Brush.linearGradient(colorList),
        style = Stroke(
            width = 12.dp.toPx(),
            cap = StrokeCap.Round,
            join = StrokeJoin.Round
        )
    )

}

깃헙 링크

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

profile
Frontend Developer

0개의 댓글