이동식 저장소

모든 터치 이벤트의 시작과 끝을 판별하는 방법 본문

Primary/Compose

모든 터치 이벤트의 시작과 끝을 판별하는 방법

해스끼 2023. 1. 14. 15:14

Compose로 혼자 뚝딱뚝딱 하던 도중 composable이 어떠한 방식으로든 터치되고 있는지 확인해야 하는 경우가 생겼다. 어떤 방식으로든 focus를 받고 있을 때 아이콘을 보여주고 싶었기 때문이다.


이 기능을 구현하려면 일단 Compose에서의 터치 이벤트를 공부해야 한다.

 

일단 터치 이벤트가 발생했는지 판단하는 ``isFocused`` 변수를 선언하자. 이 값이 true이면 터치 이벤트가 발생한 것이므로 아이콘을 보여줘야 한다. 

@Composable
fun RotatingIndicatorGradient() {
    var isFocused by rememberSaveable { mutableStateOf(false) }

    RotatingGradient {
        AnimatedVisibility(
            visible = isFocused,
            // ...
        ) {
            Icon(
                // ...
            )
        }
    }
}

터치 이벤트

터치나 드래그를 처리하고 싶다면 ``Modifier.clickable``이나 ``Modifier.draggable``을 사용해도 된다. 이 함수들은 이벤트의 발생을 간편하게 확인할 수 있고, 시각적으로 이벤트가 발생했음을 보여주기도 한다. 하지만 지금은 이벤트의 발생 자체가 아닌 이벤트의 시작과 끝을 추적해야 하므로 low level의 함수를 사용해야 한다.

 

로우 레벨 함수는 ``PointerInputScope``에 정의되어 있다. ``Modifier.pointerInput`` 내부 람다에서 ``PointerInputScope``에 접근할 수 있다. 예를 들어 클릭과 관련된 이벤트는 다음과 같이 처리할 수 있다.

Modifier.pointerInput(Unit) {
    detectTapGestures(
        onPress = { /* Called when the gesture starts */ },
        onDoubleTap = { /* Called on Double Tap */ },
        onLongPress = { /* Called on Long Press */ },
        onTap = { /* Called on Tap */ }
    )
}

``pointerInput``의 매개변수 ``Unit``은 일단 무시하자. 밑에서 다시 설명하겠다.

 

``detect...`` 함수를 사용하면 Tap, Drag, Transform 제스쳐를 개별적으로 추적할 수 있다. 함수 목록은 다음과 같다. Transform 제스쳐는 확대, 축소, 회전 제스쳐를 의미한다.

그런데 지금은 어떠한 형식으로든 UI가 터치되고 있는지를 알고 싶으므로 모든 이벤트를 관찰해야 한다. 물론 위에 있는 ``detect`` 함수를 써도 되겠지만 코드가 너무 복잡해질 것이다. 


``awaitPointerEventScope`` 함수를 사용하면 모든 터치 이벤트를 추적할 수 있다. 함수 구조는 다음과 같다.

public abstract suspend fun <R> awaitPointerEventScope(
    block: suspend AwaitPointerEventScope.() -> R
): R

``AwaitPointerEventScope``에도 터치 이벤트를 처리할 수 있는 함수가 많이 정의되어 있다. 이 scope 안에서 코드를 완성해 보자.

 

지금 필요한 알고리즘은 다음과 같다.

  • ``isFocused``가 ``true``라면 현재 발생하고 있는 터치 이벤트가 끝날 때까지 기다린 후, ``isFocused``에 ``false``를 할당한다.
  • ``isFocused``가 ``false``라면 터치 이벤트가 발생할 때까지 기다린 후, 이벤트가 발생할 때 ``isFocused``에 ``true``를 할당한다.

``AwaitPointerInputScope``의 함수를 활용하면 다음과 같은 코드를 작성할 수 있다.

awaitPointerEventScope {
    isFocused = if (isFocused) {
        // 모든 터치 이벤트가 끝날 때까지 기다림
        while (waitForUpOrCancellation() == null);
        false
    } else {
        awaitFirstDown(false)
        true
    }
}

 

Up? Cancel?

터치 이벤트에서 Up은 이벤트가 종료될 때까지 포인터가 움직이지 않았음을 의미한다. 포인터란 화면을 터치할 수 있는 모든 수단(손가락, 마우스, 스타일러스 등)을 의미한다. 예를 들어 어떤 위치를 터치한 후 손가락을 이동하지 않고 그 자리에서 뗀 경우가 해당된다.

 

반대로 Cancel은 포인터가 움직이고 있음을 의미한다. 

 

지금은 Up 이벤트만을 추적해야 한다. ``waitForUporCancellation()`` 함수는 up 또는 cancel 이벤트가 발생할 때까지 기다리는 suspend 함수이다. 결과가 up이라면 ``PointerInputChange`` 객체를, cancel이라면 ``null``을 반환한다. 따라서 이 함수의 반환값이 ``null``이 아닐 때까지 기다리면 up 이벤트를 받을 수 있다. 나는 body가 없는 while문을 사용하여 구현하였다.

 

``AwaitPointerEventScope``에서 이벤트를 기다리는 함수들은 터치 이벤트가 cancel됐을 때 공통적으로 null을 반환하는 듯하다.

Down?

Down은 말 그대로 포인터가 UI를 터치하는 순간을 가리킨다. ``awaitFirstDown`` 함수를 사용하여 UI가 첫 번째로 터치되는 순간을 기다릴 수 있다. 매개변수는 이 글의 내용과 무관하므로 생략하겠다.


 

 

지금까지 작성한 코드는 다음과 같다.

@Composable
fun RotatingIndicatorGradient() {
    var isFocused by rememberSaveable { mutableStateOf(false) }

    RotatingGradient(
        modifier = modifier.pointerInput(Unit) {
            awaitPointerEventScope {
                isFocused = if (isFocused) {
                    // 모든 터치 이벤트가 끝날 때까지 기다림
                    while (waitForUpOrCancellation() == null);
                    false
                } else {
                    awaitFirstDown(false)
                    true
                }
            }
        }
    ) {
        AnimatedVisibility(
            visible = isFocused,
            // ...
        ) {
            Icon(
                // ...
            )
        }
    }
}

그런데 터치를 뗄 때 아이콘이 없어지지 않는다. 이벤트 종료를 제대로 수신되지 못하는 것 같다.

 

``awaitPointerEventScope``의 API 문서를 읽어보면 힌트를 얻을 수 있다.

 

A call to awaitPointerEventScope will resume with block's result after it completes.

요약하면 블럭이 한 번 실행된 후에는 다시 실행되지 않는다! 그래서 맨 처음의 down 이벤트만 수신되고 그 후의 up 이벤트는 수신되지 않는 것이다.

 

``isFocused``의 값이 바뀔 때마다 ``awaitPointerEventScope``를 다시 실행하면 될 것 같다. 고맙게도 이 기능은 ``Modifier.pointerInput``에 이미 구현되어 있다. 역시 API 문서에서 힌트를 얻을 수 있다.

Specifying the captured value as a key parameter will cause block to cancel and restart from the beginning if the value changes

``key``로 지정된 변수의 값이 바뀌면 코드가 다시 실행된다. LaunchedEffect와 비슷하다. 따라서 ``isFocused``를 key로 주면 ``isFocused``의 값에 따라 이벤트를 계속 추적할 수 있다.

 

완성된 코드는 다음과 같다.

@Composable
fun RotatingIndicatorGradient() {
    var isFocused by rememberSaveable { mutableStateOf(false) }

    RotatingGradient(
        modifier = modifier.pointerInput(isFocused) {
            awaitPointerEventScope {
                isFocused = if (isFocused) {
                    // 모든 터치 이벤트가 끝날 때까지 기다림
                    while (waitForUpOrCancellation() == null);
                    false
                } else {
                    awaitFirstDown(false)
                    true
                }
            }
        }
    ) {
        AnimatedVisibility(
            visible = isFocused,
            // ...
        ) {
            Icon(
                // ...
            )
        }
    }
}

굿

잘 된다!

 

코드 전체를 보고 싶다면 GitHub를 참고하길 바란다.

참고문헌

 

동작  |  Jetpack Compose  |  Android Developers

동작 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. Compose는 사용자 상호작용에서 생성된 동작을 감지하는 데 도움이 되는 다양한 API를 제공합니다. 이 API

developer.android.com

 

Comments