깨알 개념/Android

[Android] Pointer input - PointerInputChange, PointerEvent

interfacer_han 2025. 2. 7. 19:14

#1 개요

 

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

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

developer.android.com

위 공식 문서를 나의 언어로 정리하고, 샘플 앱을 만들어봤다.
 

#2 Pointer

#2-1 포인터는 하드웨어다

화면의 특정 좌표를 찍을(point) 수 있는 사물(하드웨어)을 의미한다. 일반적으론 손가락을 의미한다. 혹은 갤럭시의 S펜이 해당된다. 키보드는 터치 스크린의 어느 좌표를 가리킬(point) 수 없으므로 포인터가 아니다.
 

#2-2 PointerType

package androidx.compose.ui.input.pointer

...

/**
 * The device type that produces a [PointerInputChange], such as a mouse or stylus.
 */
@kotlin.jvm.JvmInline
value class PointerType private constructor(private val value: Int) {

    override fun toString(): String = when (value) {
        1 -> "Touch"
        2 -> "Mouse"
        3 -> "Stylus"
        4 -> "Eraser"
        else -> "Unknown"
    }

    companion object {
        /**
         * An unknown device type or the device type isn't relevant.
         */
        val Unknown = PointerType(0)

        /**
         * Touch (finger) input.
         */
        val Touch = PointerType(1)

        /**
         * A mouse pointer.
         */
        val Mouse = PointerType(2)

        /**
         * A stylus.
         */
        val Stylus = PointerType(3)

        /**
         * An eraser or an inverted stylus.
         */
        val Eraser = PointerType(4)
    }
}

...

(전체 소스코드)
 

안드로이드에서는 포인터의 유형이 위의 5가지로 분류된다. PointerType은 후술할 PointerInputChange에 인수로서 전달된다.
 

#3 PointerInputChange

#3-1 코드

package androidx.compose.ui.input.pointer

...

@Immutable
class PointerInputChange(
    val id: PointerId, // 인스턴스 식별을 위한 ID. 중복이 가능하다 (샘플 앱에서 확인 가능)
    val uptimeMillis: Long, // PointerInputChange 인스턴스가 발생한 시각
    val position: Offset, // '화면 상의 좌표'가 아님에 주의. 설명 참조
    val pressed: Boolean, // 터치 스크린을 누르는 PointerInputChange인지, 떼는 PointerInputChange인지 명시
    val pressure: Float, // 포인터가 화면을 누르는 압력
    val previousUptimeMillis: Long, // 직전 PointerInputChange 인스턴스가 발생한 시각
    val previousPosition: Offset, // 직전 PointerInputChange의 position
    val previousPressed: Boolean, // 직전 PointerInputChange의 pressed
    isInitiallyConsumed: Boolean, // 다른 곳에서 이미 써먹은 PointerInputChange임을 명시
    val type: PointerType = PointerType.Touch, // #2-2 참조
    val scrollDelta: Offset = Offset.Zero // 마우스 휠을 굴린 정도값
) {
    ...
}

...

(전체 소스코드)
 

포인터(하드웨어)의 움직임을 터치 스크린으로 기록하여, 디지털라이징(소프트웨어)한 클래스다. 따라서, 안드로이드 기기의 터치 스크린을 사용할 때마다 무수히 많은 PointerInputChange 인스턴스가 생성된다.
 

#3-2 position에 대한 이해

우선 모든 position(위치)의 기준은 맨 왼쪽 맨 상단이다. 즉, 맨 ↖쪽의 좌표가 (0, 0)이다.
 
또, 기준은 스마트폰 화면이 아니다! 화면 속 컴포넌트들이 각각 고유한 기준을 가지게 된다. 따라서, 위 스마트폰 화면 도식도에서는 기준이 4개 존재할 것이다 (저 4개의 컴포넌트들을 담는 레이아웃 컴포넌트인 Box()까지 포함한다면 5개).
 
예를 들어, 사용자가 1번 Text()의 B 부분을 터치하면 PointerInputChange.position은 (2, 2)이 된다. 1번 Text()의 기준인 A에서 오른쪽으로 2만큼 아래쪽으로 2만큼 떨어져있기 때문이다. D는? A에서 오른쪽으로 -1만큼 아래쪽으로 -1만큼 떨어져 있으므로 (= 왼쪽으로 1만큼 위쪽으로 1만큼 떨어져 있으므로) (-1, -1)이 된다.
 
기준은 컴포넌트의 갯수만큼 존재한다고 했다. C의 position은, 1번 Text()가 기준이라면 (12, 7)이고 2번 Button()이 기준이라면 (6, 0)이 된다.
 
그런데 C나 D는 1번 Text()의 바깥 영역이다. 1번 Text()의 영역이 아닌데도 어째서 PointerInputChange 인스턴스가 생성될 수 있는가? 바로, 사용자가 컴포넌트 밖에서 터치한 후 손가락을 드래그하여 컴포넌트 안으로 들어오거나, 반대로 컴포넌트 안에서 터치한 후 손가락을 컴포넌트 바깥 영역으로으로 드래그할 수도 있기 때문이다. 그래서 PointerInputChange.position 값이 음수(D)이거나 컴포넌트 크기보다 큰 값(C)이 될 수 있는 것이다.
 

#4 PointerEvent

#4-1 코드

package androidx.compose.ui.input.pointer

...

/**
 * Describes a pointer input change event that has occurred at a particular point in time.
 */
expect class PointerEvent internal constructor(
    changes: List<PointerInputChange>,
    internalPointerEvent: InternalPointerEvent?
) {
    /**
     * @param changes The changes.
     */
    constructor(changes: List<PointerInputChange>)

    /**
     * The changes.
     */
    val changes: List<PointerInputChange>

    /**
     * The state of buttons (e.g. mouse or stylus buttons) during this event.
     */
    val buttons: PointerButtons

    /**
     * The state of modifier keys during this event.
     */
    val keyboardModifiers: PointerKeyboardModifiers

    /**
     * The primary reason the [PointerEvent] was sent.
     */
    var type: PointerEventType
        internal set
}

...

(전체 소스코드)
 
PointerEvent는 PointerInputChange의 리스트를 보유하며, 존재 목적은 PointerInputChange와 동일하다. 즉, 포인터의 움직임을 기록하기 위한 클래스다. PointerInputChange의 리스트를 보유하는 이유는 멀티 터치의 존재 때문이다. 손가락 1개로만 터치한다면 List<PointerInputChange>에는 원소가 하나만 존재하게 되지만, 손가락 2개 이상을 사용해 동시에 터치하는 경우에는 원소가 2개 이상 존재하게 된다.

#4-2 Modifier.pointerInput()

public fun Modifier.pointerInput(
    key1: Any?,
    block: suspend PointerInputScope.() -> Unit
): Modifier

pointerInput()은 PointerInputScope를 보유한다. PointerInputScope 영역에는 후술할 awaitPointerEventScope()나 다음 게시글에서 설명할 detectTapGesture() 등을 통해, PointerEvent를 감지하고 처리할 수 있다. 한 마디로, PointEvent 관련 프로그래밍을 위한 기초 토대라 할 수 있겠다.
 
PointerInputScope()에 suspend 키워드가 붙어 있는 걸 확인할 수 있다. 즉, PointerInputScope() 속은 비동기 코드의 영역이 되어 Coroutines 코드들을 위치시킬 수 있게 된다는 얘기다. 또, 그래야만(비동기 코드의 영역이어야만) 한다. PointerEvent 인스턴스는 무수히 많이 생성되어 쏟아지듯이 pointerInput()에게 전달될텐데, 이를 동기 방식으로 처리하려고 하면 화면이 엄청나게 버벅일 것이다.
 
pointerInput()에 전달할 수 있는 인수 중엔 "key"라는 이름의 인수가 존재한다. "key" 자리에는 주로 그 값이 변할 수 있는 객체가 들어간다. 그리고 Jetpack Compose에서는 매개변수로 전달된 값이 변경되면 Recomposition이 일어난다 (꼭 State.value가 변해야만 Recomposition이 일어나는 게 아니다) . 다시 말해 이는 "key" 값의 변화에 의한 Compose Recomposition 때, pointerInput()이 재실행된다는 것을 의미한다 (물론 암시적으로 실행될 것이므로 프로그래머가 크게 신경쓸 부분은 아닐 것이다).
 
pointerInput()은 Modifier.pointerInput( ... ).pointerInput( ... ).pointerInput( ... )처럼 여러 번 메소드 체이닝할 수도 있다 (#4-5 참조).
 

#4-3 AwaitPointerEventScope()

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

pointerInput() 내부에 넣을 수 있는 CoroutineScope()다. PointerInputScope()는 이미 비동기 코드의 영역인데 그 내부에 또 굳이 이 CoroutineScope를 넣는 이유는, AwaitPointerEventScope()가 일반적인 CoroutineScope()와는 달리 여러 PointEvent를 감지하고 손 쉽게 처리할 수 있는 메소드들을 제공하기 때문이다. 일반적인 CoroutineScope()만을 활용하면 굉장히 복잡한 로직을 짜야할 텐데 그러한 수고를 덜 수 있는 것이다.
 

#4-4 AwaitPointerEventScope()의 메소드들

가장 대표적인 메소드: awaitPointerEvent()

suspend fun awaitPointerEvent(pass: PointerEventPass = PointerEventPass.Main): PointerEvent

PointerEvent가 일어날 때까지 대기(suspend)했다가, PointerEvent가 발생하면 반환한다.
 
다른 메소드들

 

[Android] Pointer input - AwaitPointerEventScope()의 메소드들

#1 개요#1-1 이전 게시글 [Android] Pointer input - PointerInputChange, PointerEvent#1 개요 동작 이해하기  |  Jetpack Compose  |  Android Developers이 페이지는 Cloud Translation API를 통해 번역되었습니다. 동작 이해하

kenel.tistory.com

나머지 메소드들은 위 게시글에서 이어 설명한다.

 

#4-5 메소드 체이닝과 '소비'

pointerInput()은 Modifier.pointerInput( ... ).pointerInput( ... ).pointerInput( ... )처럼 여러 번 메소드 체이닝할 수도 있다. 아래의 코드를 보자.
 

Modifier
    .pointerInput(Unit) {
        while (true) {
            val event = awaitPointerEventScope { awaitPointerEvent() }
            event.changes.forEach { change ->
                if(!change.isConsumed) {
                    println("첫번째 pointerInput에서 처리: ${change.id}")
                }
            }
        }
    }
    .pointerInput(Unit) {
        while (true) {
            val event = awaitPointerEventScope { awaitPointerEvent() }
            event.changes.forEach { change ->
                if(!change.isConsumed) {
                    println("두번째 pointerInput에서 처리: ${change.id}")
                }
            }
        }
    }
    .pointerInput(Unit) {
        while (true) {
            val event = awaitPointerEventScope { awaitPointerEvent() }
            event.changes.forEach { change ->
                if(!change.isConsumed) {
                    println("세번째 pointerInput에서 처리: ${change.id}")
                }
            }
            
            event.changes.forEach { it.consume() } // 이벤트 소비
        }
    }
    .pointerInput(Unit) {
        while (true) {
            val event = awaitPointerEventScope { awaitPointerEvent() }
            event.changes.forEach { change ->
                if(!change.isConsumed) {
                    println("네번째 pointerInput에서 처리: ${change.id}")
                }
            }
        }
    }

while(true) { ... }
우선 코드 속에 있는 while(true) { ... }에 대해 설명하고 2개 이상의 메소드 체이닝 설명으로 넘어가겠다. 일반적으로 우리는 PointerEvent를 한 번만 감지하고 말지 않을 것이다. 따라서 awaitEventScope() 따위를 while(true) { ... }로 감싸, 새로 생성된 PointerEvent 인스턴스에 계속 대기하게끔 만들어준 것이다.

2개 이상의 메소드 체이닝
pointerInput()이 중복 메소드 체이닝되면, PointerEvent는 pointerInput()이 메소드 체이닝된 순서대로 전달된다. 즉 println()의 출력은 "첫번째 pointerInput에서 ... 두번째 pointerInput에서 ... 세번째 pointerInput에서 ..."가 될테다.
 
'소비 (consume)'
그러나, "네번째 pointerInput에서 ..."는 출력되지 않을 것이다. 왜냐하면 세번째 pointerInput()에서 이벤트를 '소비'했기 때문이다. '소비'는 #3-1에 있는 PointerInputChange의 프로퍼티 목록에서 이미 한 번 봤었다. 어떤 PointerInputChange에 대해 내가 의도한 동작을 전부 처리했다면, 더 이상 이 PointerInputChange는 활용 가치가 없다. 아니, 활용 가치가 없는 걸 넘어서 잠재적 에러의 원인이 된다. 이런 경우 PointerInputChange에 프로그래머가 '소비함'이라는 딱지를 붙이고, 위 코드에서처럼 if 분기문으로 제어할 수 있다.

 

#5 터치 로그 확인 앱

#5-1 개요

PointerEvent 및 그 속의 PointerInputChange를 Log 메시지로 받을 수 있는 앱을 만들었다.
 

#5-2 핵심 코드

Column(
    modifier = Modifier
        .fillMaxWidth()
        .padding(innerPadding)
        .verticalScroll(rememberScrollState()),
    verticalArrangement = Arrangement.Top,
    horizontalAlignment = Alignment.CenterHorizontally
) {
    repeat(20) {
        val componentName = "Item ${it + 1}"

        Text(
            text = componentName,
            modifier = Modifier
                .background(Color.LightGray)
                .pointerInput(Unit) { 
                    awaitPointerEventScope {
                        while (true) {
                            val event = awaitPointerEvent() // suspend 함수
                            Log.d(componentName, "PointerEvent.type = ${event.type}")
                            event.changes.forEach { change ->
                                Log.d(
                                    componentName,
                                    """
                                        PointerInputChange(
                                            id = ${change.id}, 
                                            uptimeMillis = ${change.uptimeMillis},
                                            position = ${change.position}, 
                                            pressed = ${change.pressed},
                                            pressure = ${change.pressure}, 
                                            previousUptimeMillis = ${change.previousUptimeMillis},
                                            previousPosition = ${change.previousPosition}, 
                                            previousPressed = ${change.previousPressed},
                                            isInitiallyConsumed = ${change.isConsumed}, 
                                            type = ${change.type},
                                            scrollDelta = ${change.scrollDelta} 
                                        ) 
                                    """.trimIndent()
                                )
                            }
                        }
                    }
                },
            fontSize = 48.sp,
        )
        Spacer(modifier = Modifier.height(48.dp))
    }
}

PointerInputChange 및 PointerEvent를 Log를 통해 확인할 수 있는 코드다.

#5-3 완성된 앱

 

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

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

github.com

#5-2의 코드를 적용한 앱이다.
 

#6 요약

손가락(하드웨어)의 움직임을, PointerInputChange 또는 PointerEvent로 읽어낼 수 있다.
 

#7 이어지는 글

 

[Android] Pointer input - Gesture

#1 개요#1-1 이전 게시글 [Android] Pointer input - PointerInputChange, PointerEvent#1 개요 동작 이해하기  |  Jetpack Compose  |  Android Developers이 페이지는 Cloud Translation API를 통해 번역되었습니다. 동작 이해하

kenel.tistory.com