[Kotlin/Compose] SnowFall Effect 만들기

크리스마스 이브

크리스마스 이브 날 아침입니다.

아침에 밖을 보니 눈이 내리는 것을 보았습니다...

이를 기념하여 SnowFall effect를 구현해보도록 하겠습니다.

SnowFall Effect

SnowFall Effect는 화면에서 눈이 내리는 애니메이션 입니다.

이를 위해, 가장 필요한 것은 바로 입니다.

을 많이 생성하고, 화면 위에서 아래까지 떨어지도록 애니메이션을 만들어주면, 눈 내리는 모션을 제작할 수 있습니다.

 

완성 미리보기

눈 내려온당

snow Data Class

data class Snow를 생성해주겠습니다.

Snow의 경우에는, 눈의 시작 위치 x,y 좌표 그리고 눈의 크기인 radius 마지막으로 떨어지는 속도를 담은 snow가 정의된 data class입니다.

data class Snow(  
    var x : Float,  
    var y : Float,  
    var radius : Float,  
    var speed : Float  
)

변하는 Y값 설정하기

만들어진 Snow 객체 값에 변하는 Y값과 Speed를 더해주어 움직이는 모션을 만들어줄 수 있습니다.

애니메이션은 반복하여 재생할 것이므로, infinitiTransition을 통해 무한한 애니메이션을 만들어주었습니다.

val offsetY by infiniteTransition.animateFloat(  
    initialValue = 0f,  
    targetValue = screenHeight,  
    animationSpec = infiniteRepeatable(  
        animation = tween(durationMillis = 10000, easing = LinearEasing),  
        repeatMode = RepeatMode.Restart  
    ),  
)

초기값은 0이며, targetValue는 화면의 높이로 설정하였습니다.
offsetYdurationMillis동안 0f에서 screenHeight까지 이동하게 됩니다.

만약 빠른 애니메이션을 원한다면, durationMillis값을 낮게 주면 더 빠른 애니메이션을 보여줄 수 있습니다.

repeatMode의 경우에 RestartReverse 두 가지로, 원래 방향으로 반복과 반대로 움직이는 애니메이션 두 가지가 있습니다.

눈은 반대로 내리지 않으므로 ,Restart로 설정하였습니다.

눈의 위치 랜덤 생성

그런데 Snow의 각 값은 어떻게 설정을 해야할까요?

바로, Random함수를 이용하여 초기 값을 모두 다르게 설정해줍니다.

눈의 x좌표 그리고 y좌표는 모두, Random.nextFloat()를 통하여 0~1 사이의 값을 랜덤으로 생성해줍니다.

이는 나중에 눈을 그릴 때, Canvas를 사용해서 그리게 될 예정인데, CanvasDrawScope의 경우에 Canvas의 너비 그리고 높이 값을 제공해주므로, 0~1사이의 값을 곱해주면, 화면의 랜덤 위치로 설정이 가능합니다.

radiusspeed의 경우에도 코드를 계속 실행해보면서 제 기준에 맞는 값을 설정해주었습니다.

private fun makeRandomSnow(screenHeight: Float) : Snow{  
    return Snow(  
        x = Random.nextFloat(),  
        y = Random.nextFloat() * screenHeight * -1f,  
        radius = Random.nextFloat() * 3f + 2f,  
        speed = Random.nextFloat() * 1.2f + 1f  
    )  
}

스크린 높이 구하기

화면의 높이를 구하는 방법은

LocalConfiguration.current를 통해 구할 수 있습니다.

val configuration = LocalConfiguration.current  
val density = LocalDensity.current  
val screenHeight = with(density) { configuration.screenHeightDp.dp.toPx() }

LocalDensity를 이용하여 화면의 밀도 정보를 가져오고,
가져온 스크린 높이의 Dp 단위를 Px 단위로 변경할 수 있습니다.

Canvas의 Draw 확장 함수 만들기

위에서 잠깐 간단하게 설명한 Canvas의 확장 함수를 만들어보도록 하겠습니다.

Canvas의 경우에는, DrawScope를 통해 화면에 물체를 그릴 수 있습니다.

새로운 offsetY의 위치는 기존 snowy값과, 기존 offsetY값에 눈의 속도를 곱한 값으로 바꾸어 줍니다.

또한 눈이 땅에 닿으면 다시 눈을 다시 랜덤 위치로 배치해야 하기 때문에 size.height보다 크면, snow.y값을 랜덤으로 재생성 해주었습니다.

fun DrawScope.drawSnow(snow: Snow, offsetY: Float, screenHeight: Float) {  
    var newOffsetY = snow.y + offsetY * snow.speed  
    if (newOffsetY > size.height) {  
        snow.y = Random.nextFloat() * - screenHeight  
        newOffsetY = snow.y  
    }  
    drawCircle(Color.White, radius = snow.radius, center = Offset(snow.x * size.width, newOffsetY))  
}

다음과 같이 drawCircle을 통해 원 형태의 그림을 그려줍니다.

그리기

snow-effect를 만드는 함수를 작성해줍니다.

가장 먼저 눈의 개수는 100개로 설정하고 처음에 작성한 랜덤 snow객체를 생성하는 함수를 통해 100개의 눈을 만들어주겠습니다.

remember를 통해 Compose가 재구성 되어도 재생성되지 않도록 하였습니다

val snows = remember {  
    List(100) { makeRandomSnow(screenHeight) }  
}

다음은 무한한 애니메이션을 관리하는 객체를 생성해줍니다.
val infiniteTransition = rememberInfiniteTransition()

Canvas를 통해 화면에 그려주었습니다.

Canvas(  
    modifier = Modifier  
        .fillMaxSize()  
        .background(gradient)  
){  
    snows.forEach{ snow ->  
        drawSnow(snow,offsetY,screenHeight)  
    }  
}

Canvas의 배경의 경우에는 gradient를 통해 화사한 이미지를 넣어주었습니다

val gradient = androidx.compose.ui.graphics.Brush.Companion.linearGradient(  
    colors = listOf(Color.White, Color.Blue.copy(alpha = 0.3f), Color.Red.copy(alpha = 0.3f)),  
    start = Offset.Zero,  
    end = Offset.Infinite  
)

해결해야할 점

애니메이션이 처음 시작하고 다시 시작할 때 초기화되는 모션이 주어지는 데, 이는 나중에 시간이 남으면 해결해보고자 합니다.

전체 코드

class MainActivity : ComponentActivity() {  
    override fun onCreate(savedInstanceState: Bundle?) {  
        super.onCreate(savedInstanceState)  
        setContent {  
            SnowfalleffectTheme {  
                // A surface container using the 'background' color from the theme  
                Surface(  
                    modifier = Modifier.fillMaxSize(),  
                    color = MaterialTheme.colorScheme.background  
                ) {  
                    androidx.compose.foundation.layout.Box(  
                        modifier = Modifier  
                            .fillMaxSize()  
                    ) {  
                        val configuration = LocalConfiguration.current  
                        val density = LocalDensity.current  
                        val screenHeight = with(density) { configuration.screenHeightDp.dp.toPx() }  
                        com.shino72.snowfall_effect.SnowFallEffect(screenHeight = screenHeight)  
                    }  
                }            }        }    }  
}  

data class Snow(  
    var x : Float,  
    var y : Float,  
    var radius : Float,  
    var speed : Float  
)  

private fun makeRandomSnow(screenHeight: Float) : Snow{  
    return Snow(  
        x = Random.nextFloat(),  
        y = Random.nextFloat() * screenHeight * -1f,  
        radius = Random.nextFloat() * 3f + 2f,  
        speed = Random.nextFloat() * 1.2f + 1f  
    )  
}  

@Composable  
private fun SnowFallEffect(modifier: Modifier = Modifier , screenHeight : Float) {  
    val snows = remember {  
        List(100) { makeRandomSnow(screenHeight) }  
    }    val infiniteTransition = rememberInfiniteTransition()  
    val offsetY by infiniteTransition.animateFloat(  
        initialValue = 0f,  
        targetValue = screenHeight,  
        animationSpec = infiniteRepeatable(  
            animation = tween(durationMillis = 10000, easing = LinearEasing),  
            repeatMode = RepeatMode.Restart  
        ),  
    )  

    val gradient = androidx.compose.ui.graphics.Brush.Companion.linearGradient(  
        colors = listOf(Color.White, Color.Blue.copy(alpha = 0.3f), Color.Red.copy(alpha = 0.3f)),  
        start = Offset.Zero,  
        end = Offset.Infinite  
    )  

    Canvas(  
        modifier = Modifier  
            .fillMaxSize()  
            .background(gradient)  
    ){  
        snows.forEach{ snow ->  
            drawSnow(snow,offsetY,screenHeight)  
        }  
    }  
}  

fun DrawScope.drawSnow(snow: Snow, offsetY: Float, screenHeight: Float) {  
    var newOffsetY = snow.y + offsetY * snow.speed  
    if (newOffsetY > size.height) {  
        snow.y = Random.nextFloat() * - screenHeight  
        newOffsetY = snow.y  
    }  
    drawCircle(Color.White, radius = snow.radius, center = Offset(snow.x * size.width, newOffsetY))  
}

 

깃허브

https://github.com/Myeongcheol-shin/snowfall-effect