[Kotlin/Compose] 반응형 이미지 만들기

반응형 이미지?

완성 이미지 예시

드르르르륵ㄱ

유튜브를 보다가 아래 영상을 보게 되었다. 해당 영상에서는 사용자가 클릭함에 따라 이미지가 기울어져 반응형 이미지를 만드는 법을 보여준다.
https://youtu.be/YDCCauu4lIk?si=LiDetcR1AwOzSk-i

해당 영상에서 나오는 코드는 html로 설명되어 있다. 즉 웹 기반으로 만드는 방법을 설명한 영상이다.

이 영상을 보고, 그러면 Android에서 Compose를 통해 한 번 만들어보자 하고 도전해보았다.

pointerInteropFilter

아마 가장 필요한 Modifier함수라고 생각한다.

사용자가 터치를 하였을 때 해당 위치를 어떻게 인식하느냐에서 해당 위치 알 수 있도록 터치 이벤트를 다룰 수 있게 해준다.

오늘 사용할 터치 이벤트는 크게 4가지를 사용하였다.

  • ACTION_DOWN: 사용자가 화면을 터치할 때 발생한다.
  • ACTION_MOVE: 사용자가 화면 위에서 손가락을 움직일 때 발생한다.
  • ACTION_UP: 사용자가 화면에서 손가락을 떼었을 때 발생한다.
  • ACTION_CANCEL: 현재 터치 이벤트가 취소되었을 때 발생한다.

터치 이벤트로직은 다음과 같다.

Content사용자 클릭 -> 움직이기 -> 클릭을 때기

graphicsLayer

graphicsLayerModifier함수 중 하나로, UIScale/Rotation/Alpha/Shadow값을 변경해준다.

반응형 이미지를 만들기 위해서는 rotation을 사용해야 한다.

modifier = Modifier.graphicsLayer( rotationX = 10f)

다음과 같은 코드가 있다면, ' X축을 중심으로 10도만큼 회전한다' 라는 뜻이다.

그러면 클릭한 위치에 따라 어떻게 rotation값을 동적으로 변경해야 하는 공식은 어떻게 될까?

공식에 필요한 값들은 다음과 같다.

  1. tounchX : 사용자가 화면을 터치한 x좌표 이다. 이는 이전에서 설명한 pointerInteropFilter를 통해 얻을 수 있다.
  2. centerX : 이미지의 중앙 x좌표이다. 이는 이미지의 크기에 절반 값을 취해 얻을 수 있다.
  3. touchX - centerX : 중앙으로부터 터치 위치가 떨어져 있는지 알 수 있다.
  4. (touchX - centerX) / centerX : 떨어진 위치를 다시 중앙 x값으로 나누게 되면, -1~1사이의 값의 비율 형태로 얻을 수 있다. 즉, 중앙을 기준으로 터치한 위치의 상대적 값을 알 수 있다.

코드

상태 변수 정의

가장 먼저 필요한 상태 변수를 정의합니다.
rotationX, rotationY 3D회전의 값을 저장합니다.

cardSize는 카드의 크기를 저장하여, 터치 위치를 정하기 위해 사용합니다.

var rotationX by remember { mutableStateOf(0f) }  
var rotationY by remember { mutableStateOf(0f) }  

var cardSize by remember {  
    mutableStateOf(Rect.Zero)  
}

이미지 만들기

화면에 포켓몬 이미지를 넣어야 하기 때문에, Image를 사용해서 생성해줍니다.

이 때 png형태의 이미지를 drawable에 저장하여 사용하니, painterResource()를 이용하여 이미지를 가져왔습니다.

다음은 이미지의 크기와 위치를 가져오기 위해 onGloballyPositioned를 통해 이미지의 크기를 cardSize에 저장을 해주고,

.onGloballyPositioned { coordinates ->  
    cardSize = coordinates.boundsInParent()  
}

graphicsLayer를 통해 3D회전을 적용해줍니다.

.graphicsLayer {  
    this.rotationY = rotationY  
    this.rotationX = rotationX  
    cameraDistance = 12f * density  
}

여기서 caameraDistance3D변환을 위한 카메라와 이미지 사이의 거리를 의미합니다.
density의 경우에 사용자의 디바이스의 픽셀 수를 의미합니다. 이를 통해 다른 기기들의 각 해상도에 맞춰 변환 효과를 제공합니다.

pointerInteropFilter는 터치 이벤트를 처리합니다.

위에서 설명한 공식을 적용하여, 사용자가 눌렀을 때 해당 클릭 위치를 기반으로 rotationX/rotationY의 값을 설정해줍니다.

사용자가 클릭을 해제하면 다시 0f로 설정하여 원래 이미지로 돌아오도록 하였습니다.

.pointerInteropFilter { motionEvent ->  
    when (motionEvent.action) {  
        MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> {  
            val touchX = motionEvent.x  
            val touchY = motionEvent.y  
            rotationY = ((touchX - cardSize.width / 2) / (cardSize.width / 2)) * 15f  
            rotationX =  
                ((touchY - cardSize.height / 2) / (cardSize.height / 2)) * -15f  
            true  
        }  

        MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {  
            rotationX = 0f  
            rotationY = 0f  
            true  
        }  

        else -> false  
    }  
}

전체 코드

class MainActivity : ComponentActivity() {  
    override fun onCreate(savedInstanceState: Bundle?) {  
        super.onCreate(savedInstanceState)  
        setContent {  
            ReactedcardTheme {  
                // A surface container using the 'background' color from the theme  
                Surface(  
                    modifier = Modifier.fillMaxSize(),  
                    color = MaterialTheme.colorScheme.background  
                ) {  
                    ClickAnimateCard()  
                }  
            }        }    }  
}  

@OptIn(ExperimentalComposeUiApi::class)  
@Preview  
@Composable  
private fun ClickAnimateCard(modifier: Modifier = Modifier){  
    var rotationX by remember { mutableStateOf(0f) }  
    var rotationY by remember { mutableStateOf(0f) }  

    var cardSize by remember {  
        mutableStateOf(Rect.Zero)  
    }  

    Box(  
        contentAlignment = Alignment.Center,  
        modifier = Modifier.fillMaxSize()  
            .background(Color.DarkGray)  
    ){  
        Image(  
            painter = painterResource(id = R.drawable.rukario),  
            contentDescription = "rokario",  
            modifier = Modifier  
                .fillMaxSize()  
                .padding(15.dp)  
                .onGloballyPositioned { coordinates ->  
                    cardSize = coordinates.boundsInParent()  
                }  
                .graphicsLayer {  
                    this.rotationY = rotationY  
                    this.rotationX = rotationX  
                    cameraDistance = 12f * density  
                }  
                .pointerInteropFilter { motionEvent ->  
                    when (motionEvent.action) {  
                        MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> {  
                            val touchX = motionEvent.x  
                            val touchY = motionEvent.y  
                            rotationY = ((touchX - cardSize.width / 2) / (cardSize.width / 2)) * 15f  
                            rotationX =  
                                ((touchY - cardSize.height / 2) / (cardSize.height / 2)) * -15f  
                            true  
                        }  

                        MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {  
                            rotationX = 0f  
                            rotationY = 0f  
                            true  
                        }  

                        else -> false  
                    }  
                }  
        )  
    }  
}

깃허브 주소

https://github.com/Myeongcheol-shin/reacted-card

[GitHub - Myeongcheol-shin/reacted-card

Contribute to Myeongcheol-shin/reacted-card development by creating an account on GitHub.

github.com](https://github.com/Myeongcheol-shin/reacted-card)