[Android] 인스타 라방 하트 애니메이션 만들기

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

기존 애니메이션 분석

실제 인스타 라방 하트 애니메이션

이렇게 인스타그램에서 라이브방송을 할 때 나오는 하트 애니메이션을 만들어볼 것이다.

분석을 해보자면
1. 작은 하트들이 먼저 빠르게 지나감
2. 큰 하트들은 줄을 지어서 지나가고 벽에 부딪히는 것처럼 좌우로 움직임
3. 일정 시간이 지나면 반복

큰 하트 만들기

var bubbleState by remember {
    mutableStateOf(
        List(30) { index ->
            LiveHeart.create(
                index,
                screenSize = Size(
                    widthPx,
                    heightPx
                ),
                floatWidth = widthPx / 3f,
                density = density,
                radius = with(density) {
                    25.dp.toPx()
                },
                incrementationRatio = 0.4f,
            )
        }
    )
}

fun create(
    index: Int,
    screenSize: Size,
    density: Density,
    floatWidth: Float,
    radius: Float,
    incrementationRatio: Float = 0.4f,
    startOffset: Offset = Offset.Zero,
): LiveHeart {
    val x = screenSize.width / 2f + startOffset.x
    val y = screenSize.height + startOffset.y + 15.dp.dpToPx(density) * index

    return LiveHeart(
        offset = Offset(x, y),
        radius = radius,
        width = floatWidth,
        angle = Random.nextFloat() * (PI / 8) + PI * 3 / 2 - (PI * 1 / 16),
        backgroundAlpha = (Random.nextFloat() + 0.2f).coerceAtMost(1f),
        index = index,
        incrementationRatio = incrementationRatio,
        startOffset = startOffset
    )
}

하트와 하트 사이는 y값이 15.dp씩 차이가 나도록 하여 나란히 올라가도록 하였다.

작은 하트 만들기

var firstBubbleState by remember {
    mutableStateOf(
        List(50) { index ->
            LiveHeart.create(
                index,
                screenSize = Size(
                    widthPx,
                    heightPx
                ),
                floatWidth = widthPx,
                density = density,
                radius = with(density) {
                    15.dp.toPx()
                },
                incrementationRatio = 1.5f,
            )
        }.also {
            activeFirstHeartMotion = true
        }
    )
}

작은 하트도 동일하게 생성해주는데 incrementationRatio를 크게 설정해서 큰 하트보다 더 빨리 움직이도록 하였다.

하트 움직이기

LaunchedEffect(activeFirstHeartMotion) {
    while (isActive) {
        awaitFrame()

        firstBubbleState = firstBubbleState.map { bubble ->
            bubble.update(
                Size(
                    widthPx,
                    heightPx
                ),
                density
            )
            bubble
        }

        delay(16L)
    }
}

LaunchedEffect(activeFirstHeartMotion) {
    if (activeFirstHeartMotion) {
        delay(300)
        activeHeartMotion = true
    }
}

LaunchedEffect(activeHeartMotion) {
    if (!activeHeartMotion) return@LaunchedEffect

    while (isActive) {
        awaitFrame()
        bubbleState = bubbleState.map { bubble ->
            bubble.update(
                Size(
                    widthPx,
                    heightPx
                ),
                density
            )
            bubble
        }

        delay(16L)
    }
} 
fun update(
    screenSize: Size,
    density: Density,
) {

    if (offset.y >= screenSize.height + startOffset.y && initlaized) {
        return
    }

    val newX = offset.x + increment * cos(angle).toFloat()
    val newY = offset.y + increment * sin(angle).toFloat()

    offset = if (newX < 0 || newX > screenSize.width || newY < 0) {
        initializeOffset(screenSize, density)
    } else if (newX !in range) {
        angle = (3 * PI / 2 - angle) + (3 * PI / 2)
        Offset(
            x = newX + cos(angle).toFloat(),
            y = newY
        )
    } else {
        Offset(newX, newY)
    }

    if (offset.y < screenSize.height / 2) {
        alpha -= 0.05f
    }

    if (offset.y <= screenSize.height + startOffset.y) {
        initlaized = true
    }
}

16ms마다 위치값을 없데이트해주고 initalized를 통해 처음 초기화되어 화면 밖으로 나간 경우 외에 다시 화면 밖으로 나간 경우에는 다시 반복하지 않도록 하였다.

캔버스에 그리기

for (bubble in bubbleState) {
    val offsetX = with(density) {
        bubble.offset.x.toDp()
    }

    val offsetY = with(density) {
        bubble.offset.y.toDp()
    }

    val radiusDp = with(density) {
        bubble.radius.toDp()
    }

    Icon(
        imageVector = Icons.Default.Favorite,
        contentDescription = null,
        modifier = Modifier
            .size(radiusDp * 2)
            .offset(
                x = offsetX,
                y = offsetY
            )
            .alpha(bubble.alpha),
        tint = Color.White.copy(alpha = bubble.backgroundAlpha)
    )
}

최종 결과

전체 코드

@Composable
fun InstagramLiveHeart(
    modifier: Modifier = Modifier,
) = BoxWithConstraints(modifier) {

    val density = LocalDensity.current

    val widthPx = with(density) {
        maxWidth.toPx()
    }

    val heightPx = with(density) {
        maxHeight.toPx()
    }

    var activeFirstHeartMotion by remember {
        mutableStateOf(false)
    }

    var activeHeartMotion by remember {
        mutableStateOf(false)
    }

    var firstBubbleState by remember {
        mutableStateOf(
            List(50) { index ->
                LiveHeart.create(
                    index,
                    screenSize = Size(
                        widthPx,
                        heightPx
                    ),
                    floatWidth = widthPx,
                    density = density,
                    radius = with(density) {
                        15.dp.toPx()
                    },
                    incrementationRatio = 1.5f,

                    )
            }.also {
                activeFirstHeartMotion = true
            }
        )
    }

    var bubbleState by remember {
        mutableStateOf(
            List(30) { index ->

                LiveHeart.create(
                    index,
                    screenSize = Size(
                        widthPx,
                        heightPx
                    ),
                    floatWidth = widthPx / 3f,
                    density = density,
                    radius = with(density) {
                        25.dp.toPx()
                    },
                    incrementationRatio = 0.4f,
                )
            }
        )
    }



    LaunchedEffect(activeFirstHeartMotion) {
        while (isActive) {
            awaitFrame()

            firstBubbleState = firstBubbleState.map { bubble ->
                bubble.update(
                    Size(
                        widthPx,
                        heightPx
                    ),
                    density
                )
                bubble
            }

            delay(16L)
        }
    }


    LaunchedEffect(activeFirstHeartMotion) {

        if (activeFirstHeartMotion) {
            delay(300)
            activeHeartMotion = true
        }
    }

    LaunchedEffect(activeHeartMotion) {
        if(!activeHeartMotion) return@LaunchedEffect

        while (isActive) {
            awaitFrame()
            bubbleState = bubbleState.map { bubble ->
                bubble.update(
                    Size(
                        widthPx,
                        heightPx
                    ),
                    density
                )
                bubble
            }

            delay(16L)
        }
    }

    Box(
        modifier = Modifier
            .fillMaxSize()
    ) {

        for (bubble in firstBubbleState) {
            val offsetX = with(density) {
                bubble.offset.x.toDp()
            }

            val offsetY = with(density) {
                bubble.offset.y.toDp()
            }

            val radiusDp = with(density) {
                bubble.radius.toDp()
            }

            Icon(
                imageVector = Icons.Default.Favorite,
                contentDescription = null,
                modifier = Modifier
                    .size(radiusDp * 2)
                    .offset(
                        x = offsetX,
                        y = offsetY
                    )
                    .alpha(
                        bubble.alpha
                    ),
                tint = Color.White.copy(alpha = bubble.backgroundAlpha)
            )
        }

        for (bubble in bubbleState) {
            val offsetX = with(density) {
                bubble.offset.x.toDp()
            }

            val offsetY = with(density) {
                bubble.offset.y.toDp()
            }

            val radiusDp = with(density) {
                bubble.radius.toDp()
            }

            Icon(
                imageVector = Icons.Default.Favorite,
                contentDescription = null,
                modifier = Modifier
                    .size(radiusDp * 2)
                    .offset(
                        x = offsetX,
                        y = offsetY
                    )
                    .alpha(
                        bubble.alpha
                    ),
                tint = Color.White.copy(alpha = bubble.backgroundAlpha)
            )
        }
    }
}

class LiveHeart(
    offset: Offset,
    val radius: Float,
    val width: Float,
    angle: Double,
    val backgroundAlpha: Float,
    val index: Int,
    incrementationRatio: Float = 0.1f,
    private val startOffset: Offset = Offset.Zero,
) {

    var offset by mutableStateOf(offset)
    private var angle by mutableDoubleStateOf(angle)
    var alpha by mutableFloatStateOf(1f)

    private var range = (offset.x - width / 2)..(offset.x + width / 2)

    private val increment = radius * incrementationRatio

    private var initlaized = false

    fun update(
        screenSize: Size,
        density: Density,
    ) {

        if (offset.y >= screenSize.height + startOffset.y && initlaized) {
            return
        }

        val newX = offset.x + increment * cos(angle).toFloat()
        val newY = offset.y + increment * sin(angle).toFloat()

        offset = if (newX < 0 || newX > screenSize.width || newY < 0) {
            initializeOffset(screenSize, density)
        } else if (newX !in range) {
            angle = (3 * PI / 2 - angle) + (3 * PI / 2)
            Offset(
                x = newX + cos(angle).toFloat(),
                y = newY
            )
        } else {
            Offset(newX, newY)
        }

        if (offset.y < screenSize.height / 2) {
            alpha -= 0.05f
        }

        if (offset.y <= screenSize.height + startOffset.y) {
            initlaized = true
        }
    }

    private fun initializeOffset(
        screenSize: Size,
        density: Density,
    ): Offset {
        val x = startOffset.x + screenSize.width / 2f
        val y = startOffset.y + screenSize.height + 20.dp.dpToPx(density) * index

        alpha = 0f
        return Offset(x, y)
    }

    companion object {
        fun create(
            index: Int,
            screenSize: Size,
            density: Density,
            floatWidth: Float,
            radius: Float,
            incrementationRatio: Float = 0.4f,
            startOffset: Offset = Offset.Zero,
        ): LiveHeart {
            val x = screenSize.width / 2f + startOffset.x
            val y = screenSize.height + startOffset.y + 15.dp.dpToPx(density) * index

            return LiveHeart(
                offset = Offset(x, y),
                radius = radius,
                width = floatWidth,
                angle = Random.nextFloat() * (PI / 8) + PI * 3 / 2 - (PI * 1 / 16),
                backgroundAlpha = (Random.nextFloat() + 0.2f).coerceAtMost(1f),
                index = index,
                incrementationRatio = incrementationRatio,
                startOffset
            )
        }
    }


}

깃헙 링크

https://github.com/uuranus/compose-nature-effects

profile
Frontend Developer

0개의 댓글