[Android] 물방울이 떠오르는 효과 만들기

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

기존 애니메이션 분석하기

포켓몬 슬립에서 수면 계측을 시작하면 나오는 화면의 배경에서 물방울이 떠다니는 것 같은 효과를 만들어 볼 것이다.

분석을 해보자면
1. 모양은 링 모양 or 원 모양
2. 둘이 겹치면 그 부분만 진해진다 -> 투명도가 존재
3. 원 모양일 경우 크기가 커졌다 작아졌다 하는 모션 존재 (링 모양은 없음)
4. 천천히 움직여서 티가 잘 안 나지만 특정 가로길이 내에서만 움직이고 거기를 벗어나면 벽에 부딪힌 것처럼 방향이 변경된다.

초기값 설정하기

var bubbleState by remember {
    mutableStateOf(
        BubbleState(
            List(15) { index ->
                val x = Random.nextInt(screenWidthPx.toInt()).toFloat()
                val y = Random.nextInt(screenHeightPx.toInt()).toFloat()

                val radius = with(density) {
                    if (index < 5) {
                        Random.nextInt(25, 30).dp.toPx()
                    } else {
                        Random.nextInt(10, 25).dp.toPx()
                    }
                }

                val shape: Shape = if (radius < shapeCriteria || Random.nextInt(2) == 0) {
                    CircleShape
                } else {
                    RingShape(6.dp)
                }

                Bubble(
                    shape = shape,
                    offset = Offset(x, y),
                    radius = radius,
                    width = radius * 3,
                    angle = Random.nextFloat() * (PI / 2) + PI + (PI * 1 / 4)
                )
            }
        )
    )
}

초기 위치값은 랜덤을 이용해서 설정주었고
5번째까지는 링 모양으로 설정하고 링 모양은 radius의 크기가 25 이상, 그 이하면 원 모양으로 설정해주었다.

위치 업데이트하기

LaunchedEffect(Unit) {
    while (isActive) {
        awaitFrame()
        bubbleState = bubbleState.copy(
            bubbles = bubbleState.bubbles.map { bubble ->
                bubble.update(
                    Size(
                        screenWidthPx,
                        screenHeightPx
                    )
                )
                bubble
            }
        )
        delay(16L)
    }
}

16L마다 위치를 업데이트해서 60FPS으로 애니메이션이 동작하도록 하였다.

fun update(
    screenSize: Size,
) {
    val newX = offset.x + increment * cos(angle).toFloat()
    val newY = offset.y + increment * sin(angle).toFloat()

    offset = when {
        newX < 0 || newX > screenSize.width || newY < 0 -> {
            val x = Random.nextFloat() * screenSize.width
            val y = screenSize.height + radius
            range = (x - width / 2)..(x + width / 2)
            Offset(x, y)
        }
        newX !in range -> {
            angle = (3 * PI / 2 - angle) + (3 * PI / 2)
            Offset(
                x = newX + cos(angle).toFloat(),
                y = newY
            )
        }
        else -> {
            Offset(newX, newY)
        }
    }

    scale += scaleIncrement

    if (scale <= 0.6f || scale >= 1f) {
        scaleIncrement = -scaleIncrement
    }
}

화면을 벗어나면 다시 초기값을 설정해주고 가로길이 범위를 벗어나면 angle을 뒤집어서 반대쪽으로 움직이도록 해주었다.

캔버스에 그리기

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

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

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

    Box(
        modifier = Modifier
            .size(radiusDp * 2)
            .offset(
                x = offsetX,
                y = offsetY
            )
            .scale(
                if (bubble.shape == CircleShape) {
                    bubble.scale
                } else {
                    1f
                }
            )
            .background(
                color = Color.White.copy(alpha = 0.15f),
                shape = bubble.shape
            )
    )
}

최종결과

전체 코드

@Composable
fun FloatingUpEffect(

) {

    val configuration = LocalConfiguration.current
    val density = LocalDensity.current

    val screenWidthPx = with(density) {
        configuration.screenWidthDp * this.density
    }

    val screenHeightPx = with(density) {
        configuration.screenHeightDp * this.density
    }

    val shapeCriteria = with(density) {
        25.dp.toPx()
    }

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

                val x = Random.nextInt(screenWidthPx.toInt()).toFloat()
                val y = Random.nextInt(screenHeightPx.toInt()).toFloat()

                val radius = with(density) {
                    if (index < 5) {
                        Random.nextInt(25, 30).dp.toPx()
                    } else {
                        Random.nextInt(10, 25).dp.toPx()
                    }
                }

                val shape: Shape = if (radius < shapeCriteria || Random.nextInt(2) == 0) {
                    CircleShape
                } else {
                    RingShape(6.dp)
                }

                Bubble(
                    shape = shape,
                    offset = Offset(x, y),
                    radius = radius,
                    width = radius * 3,
                    angle = Random.nextFloat() * (PI / 2) + PI + (PI * 1 / 4)
                )
            }
        )
    }

    LaunchedEffect(Unit) {
        while (isActive) {
            awaitFrame()
            bubbleState = bubbleState.map { bubble ->
                bubble.update(
                    Size(
                        screenWidthPx,
                        screenHeightPx
                    )
                )
                bubble
            }
            
            delay(16L)
        }
    }

    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(
                brush = Brush.verticalGradient(
                    colors = listOf(
                        Color(0xFF007FDE),
                        Color(0xFF004CC8)
                    )
                )
            )
    ) {

        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()
            }

            Box(
                modifier = Modifier
                    .size(radiusDp * 2)
                    .offset(
                        x = offsetX,
                        y = offsetY
                    )
                    .scale(
                        if (bubble.shape == CircleShape) {
                            bubble.scale
                        } else {
                            1f
                        }
                    )
                    .background(
                        color = Color.White.copy(alpha = 0.15f),
                        shape = bubble.shape
                    )
            )

        }
    }

}

class Bubble(
    val shape: Shape,
    offset: Offset,
    val radius: Float,
    val width: Float,
    angle: Double,
) {

    var offset by mutableStateOf(offset)
    private var angle by mutableDoubleStateOf(angle)
    var scale by mutableFloatStateOf(Random.nextFloat() * 0.4f + 0.6f)

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

    private val increment = radius / 25f
    private var scaleIncrement = -0.001f

    fun update(
        screenSize: Size,
    ) {
        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) {
            val x = Random.nextFloat() * screenSize.width
            val y = screenSize.height + radius
            range = (x - width / 2)..(x + width / 2)
            Offset(x, y)
        } 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)
        }

        scale += scaleIncrement

        if (scale <= 0.6f || scale >= 1f) {
            scaleIncrement = -scaleIncrement
        }

    }

}

깃헙링크

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

profile
Frontend Developer

0개의 댓글