시배's Android

Compose Docs | Understand gestures 본문

Android/Compose Docs

Compose Docs | Understand gestures

si8ae 2023. 10. 30. 22:51
 

Understand gestures  |  Jetpack Compose  |  Android Developers

Understand gestures Stay organized with collections Save and categorize content based on your preferences. There are several terms and concepts that are important to understand when working on gesture handling in an application. This page explains the term

developer.android.com

애플리케이션에서 제스처 처리 작업을 할 때 이해해야 할 중요한 용어와 개념이 몇 가지 있습니다. 이 페이지에서는 포인터, 포인터 이벤트, 제스처라는 용어에 대해 설명하고 제스처의 다양한 추상화 수준을 소개합니다. 또한 이벤트 소비 및 전파에 대해서도 자세히 살펴봅니다.

Definitions

  • Pointer : 애플리케이션과 상호 작용하는 데 사용할 수 있는 물리적 개체입니다. 모바일 디바이스에서 가장 일반적인 포인터는 터치스크린과 상호작용하는 손가락입니다. 또는 스타일러스를 사용하여 손가락을 대체할 수도 있습니다. 대형 화면의 경우 마우스나 트랙패드를 사용하여 디스플레이와 간접적으로 상호 작용할 수 있습니다. 입력 장치는 좌표를 '가리킬' 수 있어야 포인터로 간주되므로 예를 들어 키보드는 포인터로 간주할 수 없습니다. Compose에서 포인터 유형은 PointerType을 사용하여 포인터 변경에 포함됩니다.
  • Pointer event : 주어진 시간에 하나 이상의 포인터와 애플리케이션의 저수준 상호 작용을 설명합니다. 화면에 손가락을 올리거나 마우스를 드래그하는 등의 모든 포인터 상호 작용은 이벤트를 트리거합니다. Compose에서 이러한 이벤트에 대한 모든 관련 정보는 PointerEvent 클래스에 포함되어 있습니다.
  • Gesture : 단일 동작으로 해석할 수 있는 일련의 포인터 이벤트입니다. 예를 들어, 탭 제스처는 아래쪽 이벤트와 위쪽 이벤트의 시퀀스로 간주할 수 있습니다. 탭, 드래그 또는 변형과 같이 많은 앱에서 사용하는 일반적인 제스처가 있지만 필요한 경우 고유한 사용자 지정 제스처를 만들 수도 있습니다.

Different levels of abstraction

제트팩 컴포즈는 제스처 처리를 위한 다양한 수준의 추상화를 제공합니다. 최상위 레벨은 component 지원입니다. 버튼과 같은 컴포저블에는 자동으로 제스처 지원이 포함됩니다. 커스텀 컴포넌트에 제스처 지원을 추가하려면 임의의 컴포저블에 클릭 가능과 같은 제스처 수정자를 추가하면 됩니다. 마지막으로 사용자 지정 제스처가 필요한 경우 pointerInput 수정자를 사용할 수 있습니다.

 

원칙적으로 필요한 기능을 제공하는 가장 높은 수준의 추상화를 기반으로 빌드하세요. 이렇게 하면 레이어에 포함된 모범 사례를 활용할 있습니다. 예를 들어 Button 접근성을 위해 사용되는 의미론적 정보를 많이 포함하며, 이는 원시 pointerInput 구현보다 많은 정보를 포함하는 clickable보다 많은 정보를 포함합니다.

Component support

Compose의 많은 기본 컴포넌트에는 일종의 내부 제스처 처리가 포함되어 있습니다. 예를 들어 LazyColumn은 콘텐츠를 스크롤하여 드래그 제스처에 반응하고, Button은 누를 때 잔물결을 표시하며, SwipeToDismiss 컴포넌트에는 요소를 해제하는 스와이프 로직이 포함되어 있습니다. 이러한 유형의 제스처 처리는 자동으로 작동합니다.

 

내부 제스처 처리 외에도 호출자가 제스처를 처리해야 하는 컴포넌트도 많습니다. 예를 들어 버튼은 탭을 자동으로 감지하고 클릭 이벤트를 트리거합니다. 제스처에 반응하도록 버튼에 onClick 람다를 전달하면 됩니다. 마찬가지로 사용자가 슬라이더 핸들을 드래그하는 것에 반응하도록 슬라이더에 onValueChange 람다를 추가할 수 있습니다.

 

Use Case에 적합하다면 컴포넌트에 포함된 제스처를 선호하세요. 초점 접근성에 대한 기본 지원이 포함되어 있고 테스트가 되어 있기 때문입니다. 예를 들어 버튼은 특별한 방식으로 표시되어 접근성 서비스에서 클릭 가능한 요소가 아닌 버튼으로 올바르게 설명됩니다.

// Talkback: "Click me!, Button, double tap to activate"
Button(onClick = { /* TODO */ }) { Text("Click me!") }
// Talkback: "Click me!, double tap to activate"
Box(Modifier.clickable { /* TODO */ }) { Text("Click me!") }

Add specific gesutres to arbitrary composables with modifiers

 

임의의 컴포저블에 제스처 modifier를 적용하여 컴포저블이 제스처를 수신하도록 만들 수 있습니다. 예를 들어, 일반 상자를 클릭 가능하게 만들어 탭 제스처를 처리하도록 하거나 수직 스크롤을 적용하여 Row에 수직 스크롤을 처리하도록 할 수 있습니다.

 

다양한 유형의 제스처를 처리할 수 있는 많은 수정자가 있습니다:

  • clickable, combinedClickable, selectable , toggleable, triStateToggleable 수정자를 사용하여 탭과 누름을 처리합니다.
  • horizontalScroll, verticalScroll 및 기타 일반적인 scrollable modifier를 사용하여 스크롤을 처리합니다.
  • draggable 및 swipeable modifier를 사용하여 드래그를 처리합니다.
  • panning, rotating, zooming 등의 멀티터치 제스처는 transformable modifier를 사용하여 처리합니다.

일반적으로 사용자 지정 제스처 처리보다 기본 제공 제스처 modifier 선호합니다. modifier는 순수한 포인터 이벤트 처리에 많은 기능을 추가합니다. 예를 들어 클릭 가능 modifier 누르기 감지를 추가할 뿐만 아니라 의미 정보, 상호 작용에 대한 시각적 표시, 호버링, 포커스 키보드 지원도 추가합니다. 클릭 가능의 소스 코드를 확인하여 기능이 어떻게 추가되고 있는지 확인할 있습니다.

Add custom gesture to arbitrary composables with pointerInput modifier

모든 제스처가 즉시 사용 가능한 제스처 modifier로 구현되는 것은 아닙니다. 예를 들어, long press, control click 또는 세 손가락 탭 후 드래그에 반응하는 modifier는 사용할 수 없습니다. 대신 자체 제스처 핸들러를 작성하여 이러한 사용자 지정 제스처를 식별할 수 있습니다. 원시 포인터 이벤트에 액세스할 수 있는 pointerInput 수정자를 사용하여 제스처 핸들러를 만들 수 있습니다.

 

다음 코드는 raw 포인터 이벤트를 수신합니다:

@Composable
private fun LogPointerEvents(filter: PointerEventType? = null) {
    var log by remember { mutableStateOf("") }
    Column {
        Text(log)
        Box(
            Modifier
                .size(100.dp)
                .background(Color.Red)
                .pointerInput(filter) {
                    awaitPointerEventScope {
                        while (true) {
                            val event = awaitPointerEvent()
                            // handle pointer event
                            if (filter == null || event.type == filter) {
                                log = "${event.type}, ${event.changes.first().position}"
                            }
                        }
                    }
                }
        )
    }
}

이 스니펫을 잘게 쪼개면 핵심 구성 요소는 다음과 같습니다:

  • pointerInput modifier. 여기에 하나 이상의 키를 전달합니다. 이러한 키 중 하나의 값이 변경되면 modifier 콘텐츠 람다가 다시 실행됩니다. 샘플은 컴포저블에 선택적 필터를 전달합니다. 해당 필터의 값이 변경되면 포인터 이벤트 핸들러를 다시 실행하여 올바른 이벤트가 기록되도록 해야 합니다.
  • awaitPointerEventScope는 포인터 이벤트를 기다리는 데 사용할 수 있는 코루틴 스코프를 생성합니다.
  • awaitPointerEvent는 다음 포인터 이벤트가 발생할 때까지 코루틴을 일시 중단합니다.

raw 입력 이벤트를 수신하는 것은 강력하지만, 원시 데이터를 기반으로 사용자 지정 제스처를 작성하는 것도 복잡합니다. 사용자 지정 제스처 생성을 단순화하기 위해 많은 유틸리티 메서드를 사용할 있습니다.

Detect full gestures

raw 포인터 이벤트를 처리하는 대신 특정 제스처가 발생하는지 수신 대기하고 적절하게 응답할 수 있습니다. AwaitPointerEventScope는 수신 대기 메서드를 제공합니다:

  • 누르기, 탭, 두 번 탭, 길게 누르기: detectTapGestures
  • Drags : detectHorizontalDragGestures, detectVerticalDragGestures, detectDragGestures 및 detectDragGesturesAfterLongPress
  • Transforms : detectTransfromGestures

이들은 최상위 수준의 감지기이므로 하나의 포인터 입력 modifier 내에 여러 개의 감지기를 추가할 없습니다. 다음 코드는 드래그가 아닌 탭만 감지합니다:

var log by remember { mutableStateOf("") }
Column {
    Text(log)
    Box(
        Modifier
            .size(100.dp)
            .background(Color.Red)
            .pointerInput(Unit) {
                detectTapGestures { log = "Tap!" }
                // Never reached
                detectDragGestures { _, _ -> log = "Dragging" }
            }
    )
}

내부적으로 detectTapGestures 메서드는 코루틴을 차단하고 두 번째 감지기에 도달하지 않습니다. 컴포저블에 제스처 리스너를 두 개 이상 추가해야 하는 경우, 대신 별도의 포인터인풋 modifier 인스턴스를 사용하세요:

var log by remember { mutableStateOf("") }
Column {
    Text(log)
    Box(
        Modifier
            .size(100.dp)
            .background(Color.Red)
            .pointerInput(Unit) {
                detectTapGestures { log = "Tap!" }
            }
            .pointerInput(Unit) {
                // These drag events will correctly be triggered
                detectDragGestures { _, _ -> log = "Dragging" }
            }
    )
}

Handle events per gesture

정의상 제스처는 pointer down 이벤트에서 시작됩니다. raw 이벤트를 통과하는 동안(true) 루프 대신 awaitEachGesture 헬퍼 메서드를 사용할 있습니다. 모든 포인터가 해제되면 제스처가 완료되었음을 나타내는 동안 메서드가 포함된 블록을 재시작합니다:

@Composable
private fun SimpleClickable(onClick: () -> Unit) {
    Box(
        Modifier
            .size(100.dp)
            .pointerInput(onClick) {
                awaitEachGesture {
                    awaitFirstDown().also { it.consume() }
                    val up = waitForUpOrCancellation()
                    if (up != null) {
                        up.consume()
                        onClick()
                    }
                }
            }
    )
}

실제로는 제스처를 식별하지 않고 포인터 이벤트에 응답하는 경우가 아니라면 거의 항상 awaitEachGesture를 사용하고 싶을 것입니다. 예를 들어 pointer down 또는 up 이벤트에 반응하지 않고 포인터가 경계에 들어오고 나갈 때만 알면 되는 hoverable 있습니다.

Wait for a specific event or sub-gesture

제스처의 공통된 부분을 식별하는 데 도움이 되는 일련의 방법이 있습니다:

awaitFirstDown으로 포인터가 내려갈 때까지 일시 중단하거나 waitForUpOrCancellation으로 모든 포인터가 올라갈 때까지 기다립니다.

awaitTouchSlopOrCancellation 및 awaitDragOrCancellation을 사용하여 저수준 드래그 리스너를 생성합니다. 제스처 핸들러는 먼저 포인터가 터치 슬로프에 도달할 때까지 일시 중단한 다음 첫 번째 드래그 이벤트가 발생할 때까지 일시 중단합니다. 단일 축을 따라 드래그하는 데만 관심이 있는 경우 awaitHorizontalTouchSlopOrCancellation과 awaitHorizontalDragOrCancellation을 함께 사용하거나, 대신 awaitVerticalTouchSlopOrCancellation과 awaitVerticalDragOrCancellation을 함께 사용하면 됩니다.

길게 누를 때까지 awaitLongPressOrCancellation으로 일시 중단합니다.

드래그 이벤트를 지속적으로 수신하려면 드래그 메서드를 사용하고, 축에서 드래그 이벤트를 수신하려면 수평 드래그 또는 수직 드래그를 사용합니다.

Event dispatching and hit-testing

포인터 이벤트는 컴포저블 계층 구조로 디스패치됩니다. 새 포인터가 첫 번째 포인터 이벤트를 트리거하는 순간, 시스템은 "적격" 컴포저블에 대한 히트 테스트를 시작합니다. 컴포저블은 포인터 입력 처리 기능이 있는 경우 적격으로 간주됩니다. 히트 테스트는 UI 트리의 위쪽에서 아래쪽으로 진행됩니다. 포인터 이벤트가 해당 컴포저블의 범위 내에서 발생하면 컴포저블이 "히트"됩니다. 이 프로세스는 히트 테스트를 긍정적으로 통과한 컴포저블 체인을 생성합니다.

기본적으로 트리의 같은 레벨에 적격 컴포저블이 여러 개 있는 경우 z-인덱스가 가장 높은 컴포저블만 "히트"됩니다. 예를 들어, 상자에 겹치는 버튼 컴포저블을 두 개 추가하면 맨 위에 그려진 컴포저블만 포인터 이벤트를 수신합니다. 이론적으로 이 동작을 재정의하려면 자신만의 PointerInputModifierNode 구현을 생성하고 sharePointerInputWithSiblings를 true로 설정하면 됩니다.

동일한 포인터에 대한 추가 이벤트는 동일한 컴포저블 체인으로 전송되며 이벤트 전파 로직에 따라 흐릅니다. 시스템은 포인터에 대해 이상 히트 테스트를 수행하지 않습니다. , 체인의 컴포저블은 해당 컴포저블의 범위 밖에서 이벤트가 발생하더라도 해당 포인터에 대한 모든 이벤트를 수신합니다. 체인에 포함되지 않은 컴포저블은 포인터가 자신의 바운드 안에 있더라도 포인터 이벤트를 수신하지 않습니다.

Event consumption

사용자가 북마크 버튼을 탭하면 버튼의 onClick 람다가 해당 제스처를 처리합니다. 사용자가 목록 항목의 다른 부분을 탭하면 ListItem 해당 제스처를 처리하고 해당 문서로 이동합니다. 포인터 입력의 경우 버튼이 이벤트를 소비해야 부모가 이상 반응하지 않는다는 것을 있습니다. 기본 구성 요소에 포함된 제스처와 일반적인 제스처 modifier  소비 동작을 포함하지만, 사용자 지정 제스처를 직접 작성하는 경우에는 이벤트를 수동으로 소비해야 합니다. 작업은 PointerInputChange.consume 메서드를 사용하여 수행합니다:

Modifier.pointerInput(Unit) {

    awaitEachGesture {
        while (true) {
            val event = awaitPointerEvent()
            // consume all changes
            event.changes.forEach { it.consume() }
        }
    }
}

이벤트를 소비해도 다른 컴포저블로의 이벤트 전파가 중지되지는 않습니다. 대신 컴포저블은 소비된 이벤트를 명시적으로 무시해야 합니다. 커스텀 제스처를 작성할 때는 이벤트가 이미 다른 요소에 의해 소비되었는지 확인해야 합니다:

Modifier.pointerInput(Unit) {
    awaitEachGesture {
        while (true) {
            val event = awaitPointerEvent()
            if (event.changes.any { it.isConsumed }) {
                // A pointer is consumed by another gesture handler
            } else {
                // Handle unconsumed event
            }
        }
    }
}

Event propagation

포인터 이벤트는 세 번의 "패스" 동안 이러한 컴포저블을 각각 세 번 통과합니다:

 

초기 패스에서는 이벤트가 UI 트리의 상단에서 하단으로 흐릅니다. 이 흐름을 통해 부모는 자식이 이벤트를 소비하기 전에 이벤트를 가로챌 수 있습니다. 예를 들어, 툴팁은 길게 누르는 동작을 자식에게 전달하는 대신 가로채야 합니다. 이 예제에서 ListItem은 Button보다 먼저 이벤트를 수신합니다.

메인 패스에서 이벤트는 UI 트리의 리프 노드에서 UI 트리의 루트까지 흐릅니다. 이 단계는 일반적으로 제스처를 소비하는 곳이며 이벤트를 수신할 때 기본 패스입니다. 이 패스에서 제스처를 처리한다는 것은 리프 노드가 부모 노드보다 우선한다는 것을 의미하며, 이는 대부분의 제스처에서 가장 논리적인 동작입니다. 이 예제에서 Button은 ListItem보다 먼저 이벤트를 수신합니다.

최종 패스에서 이벤트는 UI 트리의 상단에서 리프 노드로 한 번 더 흐릅니다. 이 흐름을 통해 스택의 상위 요소는 상위 요소의 이벤트 소비에 응답할 수 있습니다. 예를 들어 버튼은 누름이 스크롤 가능한 부모를 드래그하는 것으로 바뀌면 리플 표시가 제거됩니다.

시각적으로 이벤트 흐름은 다음과 같이 표현할 있습니다:

Modifier.pointerInput(Unit) {
    awaitPointerEventScope {
        val eventOnInitialPass = awaitPointerEvent(PointerEventPass.Initial)
        val eventOnMainPass = awaitPointerEvent(PointerEventPass.Main) // default
        val eventOnFinalPass = awaitPointerEvent(PointerEventPass.Final)
    }
}

Test gestures

테스트 메서드에서 performTouchInput 메서드를 사용하여 포인터 이벤트를 수동으로 전송할 있습니다. 이를 통해 핀치 또는 클릭과 같은 높은 수준의 전체 제스처 또는 커서를 특정 픽셀만큼 이동하는 것과 같은 낮은 수준의 제스처를 수행할 있습니다:

composeTestRule.onNodeWithTag("MyList").performTouchInput {
    swipeUp()
    swipeDown()
    click()
}