제스처 감지하기

손현수·2024년 4월 12일

안드로이드 Compose

목록 보기
23/25

제스처는 사용자와 애플리케이션 사이의 커뮤니케이션 형태로 구현할 수 있다. 스와이프 모션으로 페이지를 넘기거나, 두 손가락으로 이미지를 확대/축소하는 등의 제스처는 애플리케이션과 인터랙션하는 전형적인 방법이다.

컴포즈 제스처 식별

컴포즈에서는 여러 인스턴스를 통해 두 가지 제스처 감지 방법을 제공한다.

  • 제스처 감지 모디파이어
  • PointerInputScope 인터페이스
    PointerInputScope 인터페이스가 추가 코딩을 해야 하지만 좀 더 뛰어난 제스처 감지 능력을 제공한다.

클릭 제스처 감지하기

클릭 제스처는 clickable 모디파이어를 이용해 모든 보이는 컴포저블에서 감지할 수 있다. 이 모디파이어는 후행 람다를 포함한다.

SomeComposable(
	modifier = Modifier.clickable { }
)

MainActivity의 코드를 다음과 같이 작성한다.

@Composable
fun MainScreen() {
    ClickDemo()
}

@Composable
fun ClickDemo() {
    var colorState by remember { mutableStateOf(true) }
    var bgColor by remember { mutableStateOf(Color.Blue) }

    val clickHandler = {
        colorState = !colorState

        if (colorState == true) {
            bgColor = Color.Blue
        } else {
            bgColor = Color.DarkGray
        }
    }

    Box(
        modifier = Modifier
            .clickable{ clickHandler() }
            .background(bgColor)
            .size(100.dp)
    )
}

ClickDemo 컴포저블의 배경색을 클릭에 따라 변경하기 위한 코드이다.

PointerInputScope를 이용해 탭 감지하기

간단한 클릭 제스처를 감지할 때는 clickable 모디파이어가 유용하지만, 이는 탭, 프레스, 롱 프레스, 더블탭 등을 구분하지 못한다. 이들을 구분하기 위해서는 PointerInputScope의 detectTapGestures() 함수를 활용해야 한다.

SomeComposable(
	Modifier
    	.pointerInput(Unit) {
        	detectTapGestures(
            	onPress = {},
                onDoubleTap = {},
                onLongPress = {},
                onTap = {}
            )
        }
)

MainActivity의 코드를 다음과 같이 작성한다.

@Composable
fun TapPressDemo() {
    var textState by remember {
        mutableStateOf("Waiting ....")
    }

    var tapHandler = {status: String ->
        textState = status
    }

    Column(
        horizontalAlignment = Alignment.CenterHorizontally,
        modifier = Modifier.fillMaxSize()
    ) {
        Box(
            Modifier
                .padding(10.dp)
                .background(Color.Blue)
                .size(100.dp)
                .pointerInput(Unit) {
                    detectTapGestures(
                        onPress = { tapHandler("onPress Detected") },
                        onDoubleTap = { tapHandler("onDoubleTap Detected") },
                        onLongPress = { tapHandler("onLongPress Detected") },
                        onTap = { tapHandler("onTap Detected") }
                    )
                }
        )
        Spacer(modifier = Modifier.height(10.dp))

        Text(textState)
    }
}

위의 코드를 실행하면 detectTapGesture() 함수가 제스처를 감지하면 tapHandler가 호출되고, 감지된 제스처 타입을 설명하는 새로운 문자열이 Text 컴포넌트에 전달되어 표시된다.

드래그 제스처 감지하기

draggable() 모디파이어를 적용하면 컴포넌트에서의 드래그 제스처를 감지할 수 있다. 이 모디파이어는 움직임이 시작된 위치로부터의 오프셋을 상태로 저장한다. 이 인스턴스는 rememberDraggable() 함수를 호출해서 만든다. 이 상태를 이용해 이후 제스처의 좌표로 드래그된 컴포넌트의 위치를 움직인다. draggable()을 호출할 때는 수직/수평 제스처 감지 여부를 전달한다.

MainActivity의 코드를 다음과 같이 작성한다.

@Composable
fun DragDemo() {
    Box(
        modifier = Modifier.fillMaxSize()
    )

    var xOffset by remember { mutableStateOf(0f) }

    Box(
        modifier = Modifier
            .offset { IntOffset(xOffset.roundToInt(), 0) }
            .size(100.dp)
            .background(Color.Blue)
            .draggable(
                orientation = Orientation.Horizontal,
                state = rememberDraggableState { distance ->
                    xOffset += distance
                }
            )
    )
}

위의 코드는 state를 만들어 x축의 현재 오프셋을 저장하고 이를 드래그할 수 있는 Box의 x 좌표로 이용한다.

draggable 모디파이어는 orientation 파라미터를 수평으로 설정한 뒤 Box에 적용된다. state 파라미터는 rememberDraggableState() 함수를 호출해 설정되며, 후행 람다를 이용해 현재 델타값을 얻어서 xOffset 상태에 추가한다. 결과적으로 box가 드래그 제스처 방향으로 이동한다.

draggable 모디파이어는 수평 또는 수직 방향으로만 드래그 제스처를 지원할 때 유용하다. 여러 방향의 드래그 조작을 지원하려면 PointerInputScope의 detectDragGestures 함수를 이용해야 한다.

PointerInputScope를 이용해 드래그 제스처 감지하기

PointerInputScope의 detectDragGestures 함수를 이용하면 수직 및 수평 동시 조작을 지원한다.

SomeComposable() {
	Modifier
    	.pointerInput(Unit) {
        	detectDragGetstures { distance ->
            	xOffset += distance.x
                yOffset += distance.y
            }
        }
}

MainActivity의 코드를 다음과 같이 작성한다.

@Composable
fun PointerInputDrag() {
    Box(
        modifier = Modifier.fillMaxSize()
    ) {
        var xOffset by remember { mutableStateOf(0f) }
        var yOffset by remember { mutableStateOf(0f) }

        Box(
            Modifier
                .offset { IntOffset(xOffset.toInt(), yOffset.toInt()) }
                .background(Color.Blue)
                .size(100.dp)
                .pointerInput(Unit) {
                    detectDragGestures {_, distance ->
                        xOffset += distance.x
                        yOffset += distance.y
                    }
                }
        )
    }
}

detectDragGestures 람다는 distance라는 이름의 Offset 객체를 전달하며, 이 객체로부터 가장 마지막으로 드래그한 x, y 오프셋값을 알 수 있다. 이들은 각각 xOffset, yOffset 상태에 추가되며 Box 컴포넌트는 화면에서 드래그 움직임을 따라가게 된다.

scrollable 모디파이어를 이용해 스크롤하기

scrollable() 모디파이어를 이용하면 리스트 컴포넌트 외에도 스크롤 제스처를 적용할 수 있다. 수직 또는 수평 방향의 제스처만 지원하며 scrollable 상태는 rememberScrollableState() 함수를 이용해 관리된다. 람다를 통해 스크롤 제스처에 의해 이동하는 거리에 접근할 수 있다.

MainActivity의 코드를 다음과 같이 작성한다.

@Composable
fun ScrollableModifier() {
    var offset by remember {
        mutableStateOf(0f)
    }

    Box(
        Modifier
            .fillMaxSize()
            .scrollable(
                orientation = Orientation.Vertical,
                state = rememberScrollableState { distance ->
                    offset += distance
                    distance
                }
            )
    ) {
        Box (modifier = Modifier
            .size(90.dp)
            .offset {IntOffset(0, offset.roundToInt())}
            .background(Color.Red))
    }
}

스크롤 모디파이어를 이용해 스크롤하기

scrollable 모디파이어는 한 방향의 스크롤만 감지할 수 있다. 스크롤 함수는 수평 및 수직 방향의 동시 스크롤을 지원함은 물론 실제로 스크롤을 처리한다. 즉, 스크롤 동작을 구현하기 위해 새로운 오프셋을 적용하는 코드를 작성할 필요가 없다.

MainActivity의 코드를 다음과 같이 작성한다.

@Composable
fun ScrollModifiers() {
    val image = ImageBitmap.imageResource(id = R.drawable.dogdog)

    Box(modifier = Modifier
        .size(400.dp)
        .verticalScroll(rememberScrollState())
        .horizontalScroll(rememberScrollState())) {
        Canvas(modifier = Modifier
            .size(580.dp, 400.dp)) {
            drawImage(
                image = image,
                topLeft = Offset(
                    x = 0f,
                    y = 0f
                )
            )
        }
    }
}

Box 컴포넌트 안에서 일부만 표시되는 이미지를 클릭하고 드래그하면 사진이 움직이고 이미지의 다른 영역이 표시된다.

꼬집기(확대/축소) 제스처 감지하기

꼬집기 제스처는 콘텐츠의 크기(비율)를 변경하거나 확대 또는 축소 효과를 나타낼 때 이용한다. 이런 타입의 제스처들은 transformable() 모디파이어를 통해 감지한다. 이 모디파이어에는 TransformableState 타입의 상태를 파라미터로 전달해야 하며, rememberTransformableState() 함수를 호출해서 만들 수 있다. 이 함수가 받는 후행 람다는 다음 3개의 파라미터이다.

  • scaleChange: 하나의 부동소수점값. 꼬집기 제스처가 수행될 때 업데이트된다.
  • offsetChange: 현재 x, y 오프셋값을 포함하는 하나의 Offset 인스턴스, 제스처에 의해 대상 컴포넌트가 이동할 때 업데이트된다.
  • rotationChange: 하나의 부동소수점값. 회전 제스처를 감지했을 때 현재 각도를 나타낸다.

위의 3개의 파라미터는 rememberTransformableState() 함수를 호출할 때 후행 람다의 이용 여부와 관계없이 항상 선언해야 한다. 비율 변경을 추적할 때는 전형적으로 다음과 같이 TransformableState를 선언한다.

var scale by remember { mutableStateOf(1f) }

val state = rememberTransformableState { scaleChange, offsetChange, rotationChange ->
	scale *= scaleChange
}

state를 만들었다면 이를 이용해 컴포저블의 transformable() 모디파이어를 다음과 같이 호출할 수 있다.

SomeComposable(modifier = Modifier
	.transformable(state = state) {
    })

꼬집기 제스처를 실행하면 scale 상태가 업데이트되고 이를 반영하기 위해 컴포저블의 그래픽 레이어에 접근해서 scaleX, scaleY 프로퍼티를 현재 scale 상태로 변경하도록 설정하면 된다.

MainActivity의 코드를 다음과 같이 작성한다.

@Composable
fun MultiTouchDemo() {
    var scale by remember { mutableStateOf(1f) }

    val state = rememberTransformableState {
        scaleChange, offsetChange, rotationChange ->
        scale *= scaleChange
    }

    Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) {
        Box(
            modifier = Modifier
                .graphicsLayer(
                    scaleX = scale,
                    scaleY = scale
                )
                .transformable(state = state)
                .background(Color.Blue)
                .size(100.dp)
        )
    }
}

앱을 실행하면 Box 컴포넌트를 꼬집기를 통해 확대/축소하는 것이 가능하다.

회전 제스처 감지하기

@Composable
fun RotationDemo() {
    var scale by remember { mutableStateOf(1f) }
    var angle by remember { mutableStateOf(0f) }

    val state = rememberTransformableState {
            scaleChange, offsetChange, rotationChange ->
        scale *= scaleChange
        angle += rotationChange
    }

    Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) {
        Box(
            modifier = Modifier
                .graphicsLayer(
                    scaleX = scale,
                    scaleY = scale,
                    rotationZ = angle
                )
                .transformable(state = state)
                .background(Color.Blue)
                .size(100.dp)
        )
    }
}

앱을 실행한 뒤 꼬집기와 회전 제스처를 동시에 수행하면 Box 컴포넌트의 크기와 각도가 모두 바뀌는 것을 확인할 수 있다.

변환 제스처 감지하기

변환은 한 컴포넌트의 위치 변경을 포함한다.

@Composable
fun TranslationDemo() {
    var scale by remember { mutableStateOf(1f) }
    var angle by remember { mutableStateOf(0f) }
    var offset by remember { mutableStateOf(Offset.Zero) }

    val state = rememberTransformableState {
            scaleChange, offsetChange, rotationChange ->
        scale *= scaleChange
        angle += rotationChange
        offset += offsetChange
    }

    Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) {
        Box(
            modifier = Modifier
                .graphicsLayer(
                    scaleX = scale,
                    scaleY = scale,
                    rotationZ = angle,
                    translationX = offset.x,
                    translationY = offset.y
                )
                .transformable(state = state)
                .background(Color.Blue)
                .size(100.dp)
        )
    }
}

앱을 실행하면 꼬집기, 회전, 컴포넌트의 이동을 모두 사용할 수 있다.

profile
안녕하세요.

0개의 댓글