[Android] X (트위터) 좋아요 애니메이션 만들기

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

기존 애니메이션 분석하기

트위터 좋아요 애니메이션

X에서 좋아요를 표시할 때 애니메이션이 난이도가 있어서 도전해보았다.

https://x.com/_cingraham/status/661566169283981312
하트 애니메이션을 프레임별로 올려놓은 사람이 있어서 이 사람 것이랑 내가 녹화한 것을 분석해봤을 때

좋아요를 표시할 때
1. 원이 점점 커지고 하트 사이즈보다 살짝 커짐
3. 하트 사이즈보다 커질 때 핑크 -> 보라색으로 색상이 바뀜
3. 하트 사이즈보다 커지면서 안에 작은 원이 생기며 링모양이 됨
4. 링은 점점 얇아지면서 사라지고 그 안에서 하트가 점점 커짐
5. 하트는 마지막에 바운스 효과가 존재
6. 링이 되면서 사라질 때 바깥원을 기준으로 컨페티가 생성
7. 컨페티는 2개씩 짝을 이뤄서 7군데에 생성됨
8. 점점 바깥으로 이동하는데 왼쪽 컨페티보다 오른쪽 컨페티가 더 빠르게 이동
9. 바깥으로 이동하면서 크기가 줄어들면서 사라지고 색상도 변함

좋아요를 취소할 때
1. 하트가 약 1.5배정도 커짐
2. 다시 원래 사이즈로 줄어들음


이제 좋아요 애니메이션부터 만들어보자.

링 애니메이션

원이 점점 커지다가 링으로 변하는 애니메이션을 만들어보자

링 scale

링은 하트 사이즈보다 약간 큰 크기까지 커졌다가 사라진다.

var circleScale by remember { mutableFloatStateOf(0f) }

LaunchedEffect(isLiked) {
    launch {
        animate(
            initialValue = 0f,
            targetValue = 1.5f,
            animationSpec = tween(circleSizeDuration, easing = LinearEasing)
        ) { value, _ ->
            circleScale = value

            if (circleScale.toInt() == 1) {
                isHeartScaleStart = true
            }
        }
    }
}

1.5f까지 커지도록 설정하였고 1의 크기까지 커졌을 때 시작하는 애니메이션이 있기에 1f가 되었을 때 isHeartScaleStart를 true로 만들어줬다.

Animatable 안 쓰고 animate 쓴 이유는 따로 snapTo로 초기값으로 되돌리는 코드를 추가하지 않기 위해서이다.

링의 넓이

링 모양으로 생성하는데 링의 두께가 링의 크기가 절반이라서 마치 원인 것처럼 보이게 한 후, 하트 사이즈가 되면 링의 두께가 줄어들어서 사라지도록 설정하였다.

val ringWidthMax by remember {
    derivedStateOf {
        heartSizePx / 2f
    }
}

var ringWidth by remember {
     mutableFloatStateOf(ringWidthMax)
}

LaunchedEffect(isHeartScaleStart) {
    launch {
        animate(
            initialValue = ringWidthMax,
            targetValue = 0f,
            animationSpec = tween(heartSizeDuration, easing = LinearEasing)
        ) { value, _ ->
            ringWidth = value
        }
    }
}

색깔 전환

색깔 전환은 하트사이즈가 될 때부터 바뀌긴 하는데 링 두께랑 같은 속도로 진행하면 색깔 바뀌는 게 보이기 전에 사라져버려서 절반 시간동안 빠르게 진행되도록 하였다.

val circleColorAnimatable = remember {
    androidx.compose.animation.Animatable(circleColor.before)
}

LaunchedEffect(isHeartScaleStart) {
    launch {
        circleColorAnimatable.animateTo(
            circleColor.after,
            animationSpec = tween(heartSizeDuration / 2, easing = LinearEasing)
        )
    }
}

하트 애니메이션

하트 scale

isHeartScalseStart가 호출되면서 0f에서부터 원래 하트 사이즈까지 scale을 키우도록 애니메이션을 설정하였다.

var heartScale by remember { mutableFloatStateOf(0f) }

LaunchedEffect(isHeartScaleStart) {
    launch {
        animate(
            initialValue = 0f,
            targetValue = 0.9f,
            animationSpec = tween(
                durationMillis = heartSizeDuration,
                easing = LinearEasing
            )
        ) { value, _ ->
            heartScale = value
        }
    }
}

바운스 효과

마지막에 약간 바운스되는 효과가 있다.
이는 spring을 써야 하는데 애니메이션 duration을 설정해줄 수 없어서
0.9f까지 커질 때는 tween을 쓰고 마지막에만 살짝 바운스
를 주도록 하였다.

LaunchedEffect(isHeartScaleStart) {
    launch {
        animate(
            initialValue = 0f,
            targetValue = 0.9f,
            animationSpec = tween(
                durationMillis = heartSizeDuration,
                easing = LinearEasing
            )
        ) { value, _ ->
            heartScale = value
        }
        animate(
            initialValue = 0.9f,
            targetValue = 1f,
            animationSpec = spring(
                dampingRatio = 0.2f,
                stiffness = 200f
            )
        ) { value, _ ->
            heartScale = value
        }
    }
}

컨페티 애니메이션

위치 정하기

저번에 star polygon을 그릴 때처럼 각도를 이용하였고 한 번에 컨페티 2개씩 그릴 거라 총 7군데 컨페티 위치를 계산하였다.
이 위치는 바뀌지 않기에 remember 설정하지 않았다.

val confettiOffsets = getConfettiAngles(conffetiCount)

private fun getConfettiAngles(count: Int): List<Double> {
    val theta = PI * 2 / count
    var currentAngle = 0.0
    val list = mutableListOf<Double>()
    val diffAngle = PI / 36

    repeat(count) {
        list.add(currentAngle - diffAngle)
        list.add(currentAngle + diffAngle)
        currentAngle += theta
    }

    return list
}

이동 애니메이션

컨페티를 2개씩 묶었을 때 왼쪽 컨페티보다 오른쪽 컨페티가 이동하는 거리가 멀기 때문에 따로 radius 증가 애니메이션을 설정해줬다.

var firstConfettiRadius by remember { mutableFloatStateOf(size.width.toFloat()) }
var secondConfettiRadius by remember { mutableFloatStateOf(size.width.toFloat()) }

LaunchedEffect(isHeartScaleStart) {
    launch {
        animate(
            initialValue = (heartSizePx / 2) * 1.5f,
            targetValue = (heartSizePx / 2) * 2f,
            animationSpec = tween(confettiRadiusDuration, easing = LinearEasing)
        ) { value, _ ->
            firstConfettiRadius = value
        }
    }
    launch {
        animate(
            initialValue = (heartSizePx / 2) * 1.5f,
            targetValue = (heartSizePx / 2) * 2.5f,
            animationSpec = tween(confettiRadiusDuration, easing = LinearEasing)
        ) { value, _ ->
            secondConfettiRadius = value
        }
    }
}

컨페티 scale

바깥으로 이동하면서 점점 scale이 작아지면서 없어지고 오른쪽 컨페티가 더 나중에 사라지기 때문에 이것도 따로 설정해주었다.

var firstConfettiScale by remember { mutableFloatStateOf(1f) }
var secondConfettiScale by remember { mutableFloatStateOf(1f) }

LaunchedEffect(isHeartScaleStart) {
    launch {
        animate(
            initialValue = 1f,
            targetValue = 0f,
            animationSpec = tween(firstConfettiScaleDuration, easing = LinearEasing)
        ) { value, _ ->
            firstConfettiScale = value
        }
    }
    launch {
        animate(
            initialValue = 1f,
            targetValue = 0f,
            animationSpec = tween(secondConfettiScaleDuration, easing = LinearEasing)
        ) { value, _ ->
            secondConfettiScale = value
        }
    }
}

색상 전환

우선 캡쳐를 해본 결과

시작

14개의 컨페티 색상이 다 다르고..! 변경되는 색상도 다 다르다.
따라서 이 두 사진을 chatGPT에게 주고 각각의 색상을 추출할 후 서로 짝지어달라고 요청했다.

이렇게 변환된 컬러들을 Animatable 리스트로 가지고 있게 설정했다.

val confettiAnimatableColor = remember {
    confettiColors.map {
        androidx.compose.animation.Animatable(it.before)
    }
}

LaunchedEffect("confettiColor") {
    confettiAnimatableColor.forEachIndexed { index, animatable ->
        launch {
            animatable.animateTo(
                confettiColors[index].after,
                animationSpec = tween(confettiDuration, easing = LinearEasing)
            )
        }
    }
}

컨페티 그리기


val confettiRadius by remember {
    derivedStateOf {
        with(density) {
            (heartSizePx / 20f).toDp()
        }
    }
}

Box(
    modifier = Modifier
        .width(width)
        .height(height)
) {
    confettiOffsets.forEachIndexed { index, angle ->
        val offsetX = if (index % 2 == 0) {
            width / 2 + (firstConfettiRadius * sin(angle)).toDp() - confettiRadius
        } else {
            width / 2 + (secondConfettiRadius * sin(angle)).toDp() - confettiRadius
        }
        val offsetY = if (index % 2 == 0) {
            height / 2 - (firstConfettiRadius * cos(angle)).toDp() - confettiRadius
        } else {
            height / 2 - (secondConfettiRadius * cos(angle)).toDp() - confettiRadius
        }

        Box(
            modifier = Modifier
                .offset(offsetX, offsetY)
                .scale(if (index % 2 == 0) firstConfettiScale else secondConfettiScale)
                .size(confettiRadius * 2)
                .clip(shape = CircleShape)
                .background(confettiAnimatableColor[index].value)
        )
    }
}

아까 계산한 offset들은 컨페티의 중심좌표이기에 컨페티의 사이즈에 맞춰서 왼쪽 위로 이동해서 거기서 그려주도록 하였다.

또한, offset계산할 때 왼쪽, 오른쪽 순서대로 계산했기에 짝수 index면 first, 홀수 index면 second에 해당하는 애니메이션을 적용해주었다.

좋아요 취소 애니메이션

좋아요 취소는 좋아요를 표시할 때보다는 훨~~씬 간단하다.

var notLikedHeartScale by remember { mutableFloatStateOf(0f) }

LaunchedEffect(Unit) {
    animate(
        initialValue = 1.5f,
        targetValue = 1f,
        animationSpec = tween(
            durationMillis = heartSizeDuration,
            easing = LinearEasing
        )
    ) { value, _ ->
        notLikedHeartScale = value
    }
}

1.5f 크기로 커졌다가 1f로 줄어들도록 설정해주었다.

최종 결과

깃헙 링크

코드가 너무 길어서 전체 코드는 생략한다.

자세한 내용은 깃허브로

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

profile
Frontend Developer

0개의 댓글