
X에서 좋아요를 표시할 때 애니메이션이 난이도가 있어서 도전해보았다.
https://x.com/_cingraham/status/661566169283981312
하트 애니메이션을 프레임별로 올려놓은 사람이 있어서 이 사람 것이랑 내가 녹화한 것을 분석해봤을 때
좋아요를 표시할 때
1. 원이 점점 커지고 하트 사이즈보다 살짝 커짐
3. 하트 사이즈보다 커질 때 핑크 -> 보라색으로 색상이 바뀜
3. 하트 사이즈보다 커지면서 안에 작은 원이 생기며 링모양이 됨
4. 링은 점점 얇아지면서 사라지고 그 안에서 하트가 점점 커짐
5. 하트는 마지막에 바운스 효과가 존재
6. 링이 되면서 사라질 때 바깥원을 기준으로 컨페티가 생성
7. 컨페티는 2개씩 짝을 이뤄서 7군데에 생성됨
8. 점점 바깥으로 이동하는데 왼쪽 컨페티보다 오른쪽 컨페티가 더 빠르게 이동
9. 바깥으로 이동하면서 크기가 줄어들면서 사라지고 색상도 변함
좋아요를 취소할 때
1. 하트가 약 1.5배정도 커짐
2. 다시 원래 사이즈로 줄어들음
이제 좋아요 애니메이션부터 만들어보자.
원이 점점 커지다가 링으로 변하는 애니메이션을 만들어보자
링은 하트 사이즈보다 약간 큰 크기까지 커졌다가 사라진다.
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)
)
}
}
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이 작아지면서 없어지고 오른쪽 컨페티가 더 나중에 사라지기 때문에 이것도 따로 설정해주었다.
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로 줄어들도록 설정해주었다.
코드가 너무 길어서 전체 코드는 생략한다.
자세한 내용은 깃허브로