컨페티 효과는 여기저기서 많이 사용되는 효과이다. 커스텀 UI 공부를 시작했을 때부터 컨페티 효과는 구현해보고 싶었는데 어떻게 구현해야 할지 몰라서 미루고만 있었다. 그리고 드디어 3개월만에 성공하게 되었다!
우선은 컨페티 하나하나가 각각 움직이고 회전하기 때문에 Confetti 데이터 클래스를 통해서 각자의 값을 가지고 있도록 하였다.
data class Confetti(
val shape: Shape,
var offset: Animatable<Offset, AnimationVector2D>,
val color: Color,
var rotation: Animatable<Float, AnimationVector1D>,
var alpha: Animatable<Float, AnimationVector1D>,
) {
suspend fun animateAlpha() {
alpha.animateTo(
targetValue = 0f,
animationSpec = tween(3000, easing = LinearEasing)
)
}
@Composable
fun Draw() {
Box(
modifier = Modifier
.offset {
IntOffset(offset.value.x.roundToInt(), offset.value.y.roundToInt())
}
.size(
width = 10.dp,
height = 15.dp
)
.graphicsLayer {
rotationX = rotation.value
rotationY = rotation.value
rotationZ = rotation.value
}
.background(
color = color.copy(alpha = alpha.value),
shape = shape
)
)
}
}
List(numOfConfetti) {
Confetti(
shape = if (Random.nextInt(2) == 0) RoundedCornerShape(0f) else CircleShape,
offset = Animatable(tapOffset, Offset.VectorConverter),
color = randomPastelColor(),
rotation = Animatable(Random.nextFloat() * 360f),
alpha = Animatable(1f)
)
}
컨페티 모양은 직사각형과 타원 중에 하나 랜덤으로 선택되며,
color는 랜덤이지만 채도 높은 파스텔 범위내에서 선택되도록 하였고
rotation은 랜덤을 통해 각자 다른 각도에서 시작해서 같이 일정하게 돌지 않도록 하였다.
컨페티 각각은 터치 지점을 시작으로 각자 설정된 peak값까지 올라갔다가 아래쪽을 향해서 천천히 떨어진다.
근데 이제 이 과정이 포물선으로 올라갔다가 떨어져야 함 -> 부드러운 곡선? -> 베지어 곡선!!
그래서 터치 지점 ~ 피크 지점, 피크지점 ~ 착륙 지점 을 나눠서 2개의 2차 베지어 곡선에 맞춰서 컨페티가 이동하도록 구성하였다.
LaunchedEffect(size, confettiGroups.size) {
confetti.forEachIndexed { index, con ->
val direction = if (index < confetti.size / 2) 1f else -1f
val maxPeakHeight = size.height
val peakHeight = maxPeakHeight * 0.2f + Random.nextFloat() * maxPeakHeight
val heightScalingFactor = peakHeight / maxPeakHeight
val horizontalDistance = heightScalingFactor * Random.nextFloat() * size.width
launch {
val frameCount = 30
val controlPoint = Offset(
(touchPoint.x + touchPoint.x + direction * horizontalDistance) / 2,
touchPoint.y - peakHeight
)
val startPoint = touchPoint
val peakPoint = Offset(
touchPoint.x + direction * horizontalDistance,
touchPoint.y - peakHeight
)
var lastBezierPosition = Offset.Zero
repeat(frameCount) { i ->
val t = i / frameCount.toFloat()
val easedT = EaseOut.transform(t)
val bezierPosition = quadraticBezier(easedT, startPoint, controlPoint, peakPoint)
con.offset.snapTo(bezierPosition)
lastBezierPosition = bezierPosition
if (bezierPosition == peakPoint) return@repeat
delay(16L)
}
launch {
con.animateAlpha()
}
val remainingDistance = size.height - lastBezierPosition.y
val fallingDuration = (remainingDistance / 10).toInt()
val endPoint = Offset(
peakPoint.x + direction * horizontalDistance / 2f,
size.height.toFloat()
)
val control2Point = Offset(
(peakPoint.x + endPoint.x) / 2f,
peakPoint.y
)
repeat(fallingDuration) { i ->
val t = i / fallingDuration.toFloat()
val easedT = LinearOutSlowInEasing.transform(t)
val bezierPosition = quadraticBezier(easedT, peakPoint, control2Point, endPoint)
con.offset.snapTo(bezierPosition)
delay(16L)
}
}
launch {
con.rotation.animateTo(
targetValue = con.rotation.value + 360f,
animationSpec = infiniteRepeatable(
animation = tween(2000, easing = LinearEasing),
repeatMode = RepeatMode.Restart
)
)
}
}
}
peak는 터치 지점을 기준으로 특정 범위 내에서 랜덤으로 설정되고 곡선을 움직이는 속도는 Easing 곡선에 맞춰서 이동하도록 하였다.
alpha값은 피크를 찍은 지점부터 서서히 사라지도록 하였고 rotation은 상관없이 독립적으로 계속 돌도록 하였다.
컨페티의 맛은 여러번 팡팡팡! 터트리는 맛이다. 근데 지금 상태로는 내가 연속 터치를 하면 이전 컨페티들은 사라지고 새로운 컨페티가 매번 생성된다.
val confettiGroups = remember { mutableStateListOf<ConfettiGroup>() }
fun cleanUpConfetti() {
confettiGroups.removeIf { it.disappearedConfetti == it.confetti.size }
}
LaunchedEffect(size, confettiGroups.size) {
cleanUpConfetti()
confettiGroups.forEach { group ->
if (group.isAnimating) return@forEach
group.isAnimating = true
group.confetti.forEachIndexed { index, con ->
//...
repeat(fallingDuration) { i ->
val t = i / fallingDuration.toFloat()
val easedT = LinearOutSlowInEasing.transform(t)
val bezierPosition = quadraticBezier(easedT, peakPoint, control2Point, endPoint)
con.offset.snapTo(bezierPosition)
delay(16L)
}
group.disappearedConfetti++
}
}
}
그래서 이렇게 ConfettiGroup을 리스트로 작성해서 이전에 터치한 컨페티 값도 계속 가지고 있도록 하였다.
그리고, 현재 컨페티들이 다 화면에서 사라졌어도 Confetti 객체는 계속 남아있기 때문에 화면에서 다 사라졌으면 cleanUp() 해주었다.
또한, isAnimating 변수를 통해서 이미 화면에 그려지고 있는 이전 컨페티들은 새로 다시 시작하지 않도록 했다.
data class ConfettiGroup(
val confetti: List<Confetti>,
val touchPoint: Offset,
val size: IntSize,
var isAnimating: Boolean,
) {
var disappearedConfetti = 0
}
ConfettiGroup 객체는 이렇게 생겼다!
Box(
modifier = modifier
.onGloballyPositioned {
size = it.size
}
.pointerInput(Unit) {
detectTapGestures { tapOffset ->
val newConfetti = List(numOfConfetti) {
Confetti(
shape = if (Random.nextInt(2) == 0) RoundedCornerShape(0f) else CircleShape,
offset = Animatable(tapOffset, Offset.VectorConverter),
color = randomPastelColor(),
rotation = Animatable(Random.nextFloat() * 360f),
alpha = Animatable(1f)
)
}
confettiGroups.add(
ConfettiGroup(newConfetti, tapOffset, size, false)
)
}
}
) {
confettiGroups.forEach { group ->
group.confetti.forEach {
it.Draw()
}
}
content()
}
이런 식으로 터치를 할 때마다 새로운 ConfettiGroup이 추가됨 -> confettiGroup.size가 변경되었기에 LaunchedEffect가 다시 시작됨 구조로 동작한다.
이렇게 구성을 했더니 이전 컨페티가 리셋되고 다시 새로 그려지는 문제는 없어졌지만 이전 컨페티가 마지막 지점에 그대로 멈춰버리는 문제가 생겼다.
이는 LaunchedEffect 때문이다.
현재 나는 LaunchedEffect의 coroutineScope에서 launch를 하고 있는데 LaunchedEffect는 confettiGroup이 새로 생길 때마다가 이전 코루틴은 취소되고 재시작된다 -> 이 과정에서 이전 컨페티 동작이 취소되어버려서 그대로 멈춰버리는 것
그래서 따로 CoroutineScope을 만들어줬더니 해결되었다.
val coroutineScope = rememberCoroutineScope()
LaunchedEffect(size, confettiGroups.size) {
cleanUpConfetti()
confettiGroups.forEach { group ->
if (group.isAnimating) return@forEach
group.isAnimating = true
group.confetti.forEachIndexed { index, con ->
val direction = if (index < group.confetti.size / 2) 1f else -1f
val maxPeakHeight = size.height
val peakHeight = maxPeakHeight * 0.2f + Random.nextFloat() * maxPeakHeight
val heightScalingFactor = peakHeight / maxPeakHeight
val horizontalDistance = heightScalingFactor * Random.nextFloat() * size.width
coroutineScope.launch {
val frameCount = 30
val controlPoint = Offset(
(group.touchPoint.x + group.touchPoint.x + direction * horizontalDistance) / 2,
group.touchPoint.y - peakHeight
)
val startPoint = group.touchPoint
val peakPoint = Offset(
group.touchPoint.x + direction * horizontalDistance,
group.touchPoint.y - peakHeight
)
var lastBezierPosition = Offset.Zero
repeat(frameCount) { i ->
val t = i / frameCount.toFloat()
val easedT = EaseOut.transform(t)
val bezierPosition = quadraticBezier(easedT, startPoint, controlPoint, peakPoint)
con.offset.snapTo(bezierPosition)
lastBezierPosition = bezierPosition
if (bezierPosition == peakPoint) return@repeat
delay(16L)
}
coroutineScope.launch {
con.animateAlpha()
}
val remainingDistance = size.height - lastBezierPosition.y
val fallingDuration = (remainingDistance / 10).toInt()
val endPoint = Offset(
peakPoint.x + direction * horizontalDistance / 2f,
size.height.toFloat()
)
val control2Point = Offset(
(peakPoint.x + endPoint.x) / 2f,
peakPoint.y
)
repeat(fallingDuration) { i ->
val t = i / fallingDuration.toFloat()
val easedT = LinearOutSlowInEasing.transform(t)
val bezierPosition = quadraticBezier(easedT, peakPoint, control2Point, endPoint)
con.offset.snapTo(bezierPosition)
delay(16L)
}
group.disappearedConfetti++
}
coroutineScope.launch {
con.rotation.animateTo(
targetValue = con.rotation.value + 360f,
animationSpec = infiniteRepeatable(
animation = tween(2000, easing = LinearEasing),
repeatMode = RepeatMode.Restart
)
)
}
}
}
}