깨알 개념/Android

[Android] Pointer input - Gesture

interfacer_han 2025. 2. 8. 17:56

#1 개요

#1-1 이전 게시글

 

[Android] Pointer input - PointerInputChange, PointerEvent

#1 개요 동작 이해하기  |  Jetpack Compose  |  Android Developers이 페이지는 Cloud Translation API를 통해 번역되었습니다. 동작 이해하기 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저

kenel.tistory.com

위의 게시글에서 이어진다. 해당 게시글에서 먼저 PointerInputChange 및 PointerEvent에 대한 이해를 해야 본 게시글을 이해할 수 있다.

 

#1-2 공식 문서

 

동작 이해하기  |  Jetpack Compose  |  Android Developers

이 페이지는 Cloud Translation API를 통해 번역되었습니다. 동작 이해하기 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 이해해야 할 몇 가지 용어와 개념이 있

developer.android.com

이전 게시글과 마찬가지로, 공식 문서를 나의 언어로 정리하고, 샘플 앱을 만들어봤다.

 

#2 Gesture

#2-1 Gesture는 별명이다

2개 이상의 일련의 PointerEvent 조합에 붙이는 별명이다. 별도의 클래스로 존재하지는 않는다. 예를 들어, Tab Gesture는 PointerEvent.type.toString() == Press인 PointerEvent에 뒤이어 PointerEvent.type.toString() == Release인 PointerEvent가 발생한다면 이를 특별히 Tab Gesture라 부른다. 이와 비슷하게 Drag Gesture나 Transform Gesture 등이 있으며, 일련의 PointerEvent 조합을 감지하는 사용자 지정 Gesture도 만들 수 있다.
 

#2-2 기본 라이브러리 제공 제스처

Gesture(제스처) 유형 제스처를 부여하는 Modifier의 확장 함수
Tap, Press clickablecombinedClickableselectabletoggleabletriStateToggleable
Scrolling horizontalScrollverticalScrollscrollable
Dragging draggableanchoredDraggable
Multi-touch Gesture transformable

표의 2열에 있는 모든 요소는 Modifier의 확장 함수다. 즉, @Composable이 가지는 modifier 인수에 저 확장 함수들을 메소드 체이닝하여 넣어주면 Gesture를 '부여'(= 특정 PointerEvent의 조합의 감지 + 원하는 동작 명세)할 수 있다. 표에 있는 transformable은 이름에서 어떤 기능인지 직관적 파악이 힘들다 (영미권 사람은 가능하려나?). transformable은 지도 앱에서 지도 탐색, 컴포넌트 회전, 줌 인/아웃 등의 멀티 터치를 요하는 제스처 구현을 위해 사용된다고 한다.

 

#2-3 사용자 정의 제스처

Text(
    text = componentName,
    modifier = Modifier
        .background(Color.LightGray)
        .pointerInput(Unit) { 
            awaitPointerEventScope {
                while (true) {
                    val event = awaitPointerEvent() 
                    // event를 가지고 무언갈 하는 코드
                }
            }
        },
    fontSize = 48.sp,
)

위 코드는 이전 게시글에서 쓴 코드의 일부분이다. 위와 같은 형태로 사용자 정의 제스처도 만들 수 있다. 내가 처리하고 싶은 특정 PointerEvent의 조합을 감지하고, 감지됐을 때의 동작을 위 코드의 주석 부분에 넣는 식으로 말이다.

 

하지만, awaintPointerEvent()를 이용해 사용자 정의 제스처를 만드는 건, 너무나 원시(raw)적이라 구현이 복잡하다. 이런 부담을 덜어주기 위해서 Jetpack Compose에선 detectTapGesture(), detectDragGestures()같은 Gesture 감지를 위한 함수를 제공한다.

#2-4 Gesture 감지 함수 목록

Gesture(제스처) 유형 제스처를 감지하는 PointerInputScope의 확장 함수
Tap, Double Tap, Press, Long-press detectTapGestures()
Drag detectDragGestures(), detectVerticalDragGestures(), detectHorizontalDragGestures(), detectDragGesturesAfterLongPress()
Transform detectTransformGestures()

각 detect...Gestures() 함수는 내부적으로 후술할 awaitEachGesture()로 감싸여있다.

 

#2-5 awaitEachGesture()

Jetpack Compose에서 제공하는 건, Gesture 감지 함수 뿐만이 아니다. 위에서 한번 보여줬던 코드를 다시 써보겠다.

 

Text(
    text = componentName,
    modifier = Modifier
        .background(Color.LightGray)
        .pointerInput(Unit) { 
            awaitPointerEventScope {
                while (true) {
                    val event = awaitPointerEvent() 
                    // event를 가지고 무언갈 하는 코드
                }
            }
        },
    fontSize = 48.sp,
)

이 코드를 아래와 같이 간소화할 수 있다.

 

Text(
    text = componentName,
    modifier = Modifier
        .background(Color.LightGray)
        .pointerInput(Unit) {
            awaitEachGesture {
                val event = awaitPointerEvent()
                // event를 가지고 무언갈 하는 코드
            }
        },
    fontSize = 48.sp,
)

즉, awaitEachGesture()는 awaitPointerEventScope() 및 while(true)를 대체한다. 하지만, awaitEachGesture()는 보다 고수준의 영역(Scope)으로서, 더 간편한(암시적인) 코드를 짤 수 있게 도와준다. 이름에서 알 수 있듯, awaitEachGesure() 속에선 각 Gesture가 종료되면 자동으로 다음 Gesture를 기다린다. awaitPointerEventScope() 및 while(true) 구조에서 프로그래머가 Gesture가 언제 시작하고 언제 끝나는 지에 대한 코드를 일일히 짜야했던 것과 대조적이다.

 

물론, 더 암시적인 환경을 제공하다는 것은 덜 세심하다는 얘기도 된다. 아주 미세한 PointerEvent 제어가 필요하다면 awaitPointerEventScope()를 다시 써야할 수도 있을 테다.

 

#2-6 Gesture 감지 함수의 사용례

Box(
    modifier = Modifier
        .size(100.dp)
        .background(Color.Red)
        .pointerInput(Unit) {
            detectTapGestures(onTap = { offset -> log = "Tapped at $offset" })
        }
        .pointerInput(Unit) {
            detectDragGestures { _, _ -> log = "Dragging" }
        }
) {
    Text("box content")
}

메소드 체이닝

Gesture 감지 함수를 사용한 코드는 위와 같다. 하나의 pointerInput()에는 하나의 Gesture 감지 함수만을 넣어야 한다 (참조: pointerInput()의 중복 메소드 체이닝). 가령 하나의 pointerInput()에, detectTapGestures()와 detectDragGestures()를 순서대로 몽땅 넣었다고 치자. 이러면, detectDragGestures()는 실행되지 않고 오직 detectTapGestures()만 실행된다. 위에서 설명한 바와 같이, detect...Gestures() 함수는 내부적으로 awaitEachGesture()로 감싸여있기 때문이다. 따라서 이 경우엔 사용자의 'Tap' 동작을 감지했으니 그 다음 'Drag' 동작을 감지하는 일 따위는 없다. 'Tap' 동작 감지 이후에도 오직 'Tap' 동작의 감지만을 위해 대기할 것이다.

 

암시적 소비

또 각 detect...Gesture() 함수는 함수 종료 전, PointerEvent를 '소비됨' 상태로 업데이트한다 (프로그래머 입장에서는 PointerEvent가 암시적으로 소비되는 셈이다).
 

여담
pointerInput()에 절대 변하지 않는 값인 Unit을 넣었으므로, pointerInput() 코드 블록은 Recomposition때도 재실행되지 않고 쭉 유지될 것이다.
 

#3 사용자 지정 Gesture 구현

#3-1 개요

더블 클릭 시의 동작은 Jetpack Compose에서 기본적으로 제공하지만, 트리플 클릭은 제공하지 않는다. 본 게시글에서 배운 지식을 바탕으로 커스텀 Gesture로서 '트리플 클릭'을 구현해보겠다.

 

#3-2 핵심 코드

@SuppressLint("ModifierFactoryUnreferencedReceiver")
fun Modifier.addTripleClickGesture1(context: Context): Modifier {
    return Modifier.composed { // composed는 State를 가지는 Modifier의 확장 함수를 만들 수 있게함
        var tapCount by remember { mutableIntStateOf(0) } // 클릭 횟수
        var lastTapTime by remember { mutableLongStateOf(0L) } // 마지막 클릭 시간

        Modifier.pointerInput(Unit) {
            detectTapGestures {
                val currentTime = System.currentTimeMillis()
                tapCount = if (currentTime - lastTapTime < 500) { // 500밀리초 이내
                    ++tapCount
                } else {
                    1
                }
                lastTapTime = currentTime

                if (tapCount >= 3) { // 트리플 클릭 감지
                    Toast.makeText(context, "Triple Clicked!", Toast.LENGTH_SHORT).show()
                    tapCount = 0 // 초기화
                }
            }
        }
    }
}

addTripleClickGesture1()은 onClick 프로퍼티를 보유한 Button() 등에 적용하면 안 된다. 왜냐하면, Button()에서 클릭 이벤트를 우선적으로 소비하기에, detectTapGestures()에 클릭 이벤트가 떨어지지 않기 때문이다. 그래서 나는 addTripleClickGesture1()를 Box()의 modifier에 적용했다.

ChatGPT가 알려준, Coroutines을 활용한 구현

더보기
더보기
@SuppressLint("ModifierFactoryUnreferencedReceiver")
fun Modifier.addTripleClickGesture2(context: Context): Modifier {
    return Modifier.composed {
        var tapCount by remember { mutableIntStateOf(0) } // 클릭 횟수
        val coroutineScope = rememberCoroutineScope()

        Modifier.pointerInput(Unit) {
            detectTapGestures {
                tapCount++
                coroutineScope.launch {
                    delay(500) // 500밀리 동안 기다림 (연속 클릭 확인)
                    if (tapCount >= 3) { // 트리플 클릭 감지
                        Toast.makeText(context, "Triple Clicked!", Toast.LENGTH_SHORT).show()
                    }
                    tapCount = 0 // 초기화
                }
            }
        }
    }
}

addTripleClickGesture2()는 onClick 프로퍼티를 보유한 Button() 등에 적용하면 안 된다. 왜냐하면, Button()에서 클릭 이벤트를 우선적으로 소비하기에, detectTapGestures()에 클릭 이벤트가 떨어지지 않기 때문이다. 그래서 나는 addTripleClickGesture1()를 Box()의 modifier에 적용했다.

 

#3-3 완성된 앱

 

android-practice/pointer-input/TripleClickGesture at master · Kanmanemone/android-practice

Contribute to Kanmanemone/android-practice development by creating an account on GitHub.

github.com

 

#4 요약

Gesture는 특정 PointerEvent 조합의 별명이며, Gesture 제어를 위한 여러 함수가 존재한다.