Android/Android Compose

Compose + AGSL 셰이더를 이용해서 간지나는 카드 애니메이션 만들기

최데브 2024. 10. 31. 17:48

어디선가 카드를 휘리릭 돌리면 카드가 돌아가면서 번쩍거리는 멋진 인터렉션을 본 적이 있는거 같다.
셰이더나 애니메이션에 관심이 많아진 요즘 뭐라도 해보고 싶어서 뚝딱 해봤다.
 
일단 최종결과물부터 봐보자.
꽤 멋져.
 

 
자자 액티비티부터 만들어준다.

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            MetalCardFilpTheme {
                Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
                    Column(
                        modifier = Modifier.fillMaxSize(),
                        verticalArrangement = Arrangement.Center,
                        horizontalAlignment = Alignment.CenterHorizontally
                    ) {
                        InteractiveRotatingCardWithLightEffect(
                            drawableResId = R.drawable.card
                        )
                    }
                }
            }
        }
    }
}

 
별거 없다. 핵심인 InteractiveRotatingCardWithLightEffect 을 알아보자.
하나씩 잘라서 봐보자.

@RequiresApi(Build.VERSION_CODES.TIRAMISU)
@Composable
fun InteractiveRotatingCardWithLightEffect(
    drawableResId: Int
) {
    val rotationX = remember { Animatable(0f) }
    val rotationY = remember { Animatable(0f) }
    var lastDragX by remember { mutableStateOf(0f) }
    var lastDragY by remember { mutableStateOf(0f) }
    var isDragging by remember { mutableStateOf(false) }
    val coroutineScope = rememberCoroutineScope()

 
유저가 카드를 위아래 양옆으로 드래그하면 방향에 맞게 움직이게 해주고 싶었다.
그래서 X,Y 좌표들을 관리해주고
드래그가 끝나고 끝난 상태로 이상하게 멈춰있는게 아니라 수평이 맞춰진 상태로 돌아오게 하고 싶었기에 isDragging 을 넣어서 드래그 중인지를 감지하게 했다.
 
카드 이미지 표면에 금속 재질의 질감을 표현해주고 싶었다.
쉐이더 코드는 gpt 가 짜줬다. 이거까지는 나도 모르겠다. gpt 짱

val metalShaderCode = """
    uniform float2 resolution;
    uniform float rotationX;
    uniform float rotationY;

    vec3 lightDir = normalize(vec3(0.5, 1.0, 0.8)); // 빛의 방향

    vec4 main(vec2 fragCoord) {
        vec2 uv = fragCoord / resolution;
        vec3 color = vec3(0.8, 0.8, 0.9); // 금속 색상

        // 빛 반사 효과 계산
        float brightness = dot(vec3(uv - 0.5, 1.0), lightDir);
        brightness = clamp(brightness, 0.0, 1.0);

        // 회전에 따라 빛 반사가 변하는 효과 추가
        vec3 highlightColor = vec3(1.0, 1.0, 1.0) * brightness * (1.0 + rotationX * 0.5 + rotationY * 0.5);
        color = mix(color, highlightColor, 0.3);

        return vec4(color, 1.0);
    }
"""

 
이 쉐이더 코드를 가져다 쓰면 된다.
 
어떻게? 이렇게 써보자.
 

Box(
    modifier = Modifier
        .size(imageBitmap.width.dp / 2.5f, imageBitmap.height.dp / 2.5f)
        .graphicsLayer {
            this.rotationX = rotationX.value
            this.rotationY = rotationY.value
            cameraDistance = 12f * density
        }
        .drawWithCache {
        	//쉐이더에 카드의 움직임 좌표와 크기를 업데이트해줘서 실시간으로 반영시킨다,
            metalShader.setFloatUniform("rotationX", rotationX.value)
            metalShader.setFloatUniform("rotationY", rotationY.value)
            metalShader.setFloatUniform("resolution", size.width, size.height)

            onDrawWithContent {
                drawImage(imageBitmap)// 이미지를 먼저 그리고
                drawRect(
                    brush = ShaderBrush(metalShader),//쉐이더를 올린다
                    blendMode = BlendMode.Overlay, //Overlay를 사용해서 겹쳐서 보이도록 처리
                    alpha = 0.6f 
                )
            }
        }
        .pointerInput(Unit) {
            detectDragGestures(
                onDragStart = { offset ->
                    isDragging = true
                    lastDragX = offset.x
                    lastDragY = offset.y
                },
                onDragEnd = {
                    isDragging = false
                },
                onDrag = { change, dragAmount ->
                    change.consume()
                    val (x, y) = dragAmount
                    coroutineScope.launch {
                    	//이렇게 처리하면 얼마나 빠른 속도로 드래그 했냐에 따라 좌표가 달라진다.
                        rotationY.snapTo(rotationY.value + x * 0.5f)
                        rotationX.snapTo(rotationX.value - y * 0.5f)
                    }
                    lastDragX = x
                    lastDragY = y
                }
            )
        },
    contentAlignment = Alignment.Center
) {
    Text(
        text = if (rotationY.value % 360 < 180) "Front" else "Back",
        color = Color.White
    )
}

 
여기서는 사용자의 드래그를 감지하고 카드의 회전 각도를 조절하는 작업을 수행한다.

  • graphicsLayer를 사용하여 카드에 회전 애니메이션을 적용.
  • drawWithCache로 셰이더에 rotationX와 rotationY 값을 업데이트하며 빛 반사 효과를 구현.
  • detectDragGestures를 통해 드래그 제스처를 감지하고, 드래그가 끝났을 때는 회전 애니메이션을 중지.

그리고 드래그가 끝났을때 원래 카드 위치로 복원시키는 코드를 아래처럼 추가한다.
0f 위치로 돌아가는 애니메이션을 작동하게 만들어뒀다.

LaunchedEffect(isDragging) {
    if (!isDragging) {
        launch {
            rotationX.animateTo(0f, tween(1000, easing = LinearOutSlowInEasing))
        }
        launch {
            rotationY.animateTo(0f, tween(1000, easing = LinearOutSlowInEasing))
        }
    }
}

 
 
끝이다. 쉐이더를 써서 더 다채로운 뭔가를 만들어보는 간지나는 안드로이드 개발자를 해보자!
파이팅~
 
 
 
 
전체코드
 

package com.choidev.metalcardfilp

import android.graphics.RuntimeShader
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.annotation.RequiresApi
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.ShaderBrush
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.res.imageResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.choidev.metalcardfilp.ui.theme.MetalCardFilpTheme
import kotlinx.coroutines.launch

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            MetalCardFilpTheme {
                Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
                    Column(
                        modifier = Modifier.fillMaxSize(),
                        verticalArrangement =   Arrangement.Center,
                        horizontalAlignment = Alignment.CenterHorizontally
                    ) {
                        InteractiveRotatingCardWithLightEffect(
                            drawableResId = R.drawable.card
                        )
                    }
                }
            }
        }
    }
}

@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
    Text(
        text = "Hello $name!",
        modifier = modifier
    )
}

@RequiresApi(Build.VERSION_CODES.TIRAMISU)
@Composable
fun InteractiveRotatingCardWithLightEffect(
    drawableResId: Int
) {
    val rotationX = remember { Animatable(0f) }
    val rotationY = remember { Animatable(0f) }
    var lastDragX by remember { mutableStateOf(0f) }
    var lastDragY by remember { mutableStateOf(0f) }
    var isDragging by remember { mutableStateOf(false) }
    val coroutineScope = rememberCoroutineScope()

    // 이미지를 불러오기
    val imageBitmap = ImageBitmap.imageResource(id = drawableResId)

    // AGSL 셰이더 코드 설정
    val metalShaderCode = """
        uniform float2 resolution;
        uniform float rotationX;
        uniform float rotationY;

        vec3 lightDir = normalize(vec3(0.5, 1.0, 0.8)); // 빛의 방향

        vec4 main(vec2 fragCoord) {
            vec2 uv = fragCoord / resolution;
            vec3 color = vec3(0.8, 0.8, 0.9); // 금속 색상

            // 빛 반사 효과 계산
            float brightness = dot(vec3(uv - 0.5, 1.0), lightDir);
            brightness = clamp(brightness, 0.0, 1.0);

            // 회전에 따라 빛 반사가 변하는 효과 추가
            vec3 highlightColor = vec3(1.0, 1.0, 1.0) * brightness * (1.0 + rotationX * 0.5 + rotationY * 0.5);
            color = mix(color, highlightColor, 0.3);

            return vec4(color, 1.0);
        }
    """
    val metalShader = RuntimeShader(metalShaderCode)

    Box(
        modifier = Modifier
            .size(imageBitmap.width.dp / 2.5f, imageBitmap.height.dp / 2.5f)
            .graphicsLayer {
                this.rotationX = rotationX.value
                this.rotationY = rotationY.value
                cameraDistance = 12f * density
            }
            .drawWithCache {
                // 셰이더에 회전 및 해상도 정보를 업데이트
                metalShader.setFloatUniform("rotationX", rotationX.value)
                metalShader.setFloatUniform("rotationY", rotationY.value)
                metalShader.setFloatUniform("resolution", size.width, size.height)

                onDrawWithContent {
                    // 1. 원본 이미지 그리기
                    drawImage(imageBitmap)
                    // 2. 금속 반사 효과를 `BlendMode.Overlay`로 그려서 겹쳐보이도록 설정
                    drawRect(
                        brush = ShaderBrush(metalShader),
                        blendMode = BlendMode.Overlay,
                        alpha = 0.6f // 투명도를 조정하여 반사 효과가 자연스럽게 나타나도록 설정
                    )
                }
            }
            .pointerInput(Unit) {
                detectDragGestures(
                    onDragStart = { offset ->
                        isDragging = true
                        lastDragX = offset.x
                        lastDragY = offset.y
                    },
                    onDragEnd = {
                        isDragging = false
                    },
                    onDrag = { change, dragAmount ->
                        change.consume()
                        val (x, y) = dragAmount
                        coroutineScope.launch {
                            rotationY.snapTo(rotationY.value + x * 0.5f)
                            rotationX.snapTo(rotationX.value - y * 0.5f)
                        }
                        lastDragX = x
                        lastDragY = y
                    }
                )
            },
        contentAlignment = Alignment.Center
    ) {
        Text(
            text = if (rotationY.value % 360 < 180) "Front" else "Back",
            color = Color.White
        )
    }

    LaunchedEffect(isDragging) {
        if (!isDragging) {
            launch {
                rotationX.animateTo(0f, tween(1000, easing = LinearOutSlowInEasing))
            }
            launch {
                rotationY.animateTo(0f, tween(1000, easing = LinearOutSlowInEasing))
            }
        }
    }
}


@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
    MetalCardFilpTheme {
        Greeting("Android")
    }
}

 

반응형