동작 이해하기

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

정의

이 페이지의 다양한 개념을 이해하려면 다음과 같은 몇 가지 용어를 이해해야 합니다.

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

다양한 수준의 추상화

Jetpack Compose는 동작을 처리하기 위한 다양한 수준의 추상화를 제공합니다. 최상위 수준은 구성요소 지원입니다. Button와 같은 컴포저블은 동작 지원을 자동으로 포함합니다. 맞춤 구성요소에 동작 지원을 추가하려면 clickable와 같은 동작 수정자를 임의의 컴포저블에 추가하면 됩니다. 마지막으로 맞춤 동작이 필요하면 pointerInput 수정자를 사용하면 됩니다.

일반적으로 필요한 기능을 제공하는 가장 높은 수준의 추상화를 기반으로 빌드합니다. 이렇게 하면 레이어에 포함된 권장사항을 활용할 수 있습니다. 예를 들어 Button에는 원시 pointerInput 구현보다 더 많은 정보가 포함된 clickable보다 접근성에 사용되는 시맨틱 정보가 더 많이 포함되어 있습니다.

구성요소 지원

Compose의 즉시 사용 가능한 많은 구성요소에는 일종의 내부 동작 처리가 포함되어 있습니다. 예를 들어 LazyColumn은 콘텐츠를 스크롤하여 드래그 동작에 응답하고, Button는 아래로 눌렀을 때 물결 효과를 표시하며, SwipeToDismiss 구성요소에는 요소를 닫는 스와이프 로직이 포함되어 있습니다. 이러한 유형의 동작 처리는 자동으로 작동합니다.

내부 동작 처리 외에도 많은 구성요소에서 호출자가 동작을 처리하도록 요구합니다. 예를 들어 Button는 자동으로 탭을 감지하고 클릭 이벤트를 트리거합니다. onClick 람다를 Button에 전달하여 동작에 반응합니다. 마찬가지로 onValueChange 람다를 Slider에 추가하여 사용자가 슬라이더 핸들을 드래그하는 것에 반응합니다.

사용 사례에 맞는 경우 구성요소에 포함된 동작을 사용하는 것이 좋습니다. 이러한 동작은 포커스와 접근성을 위한 즉각적인 지원을 포함하고 있으며 충분한 테스트를 거쳤기 때문입니다. 예를 들어 Button는 접근성 서비스에서 클릭 가능한 요소가 아닌 버튼으로 올바르게 설명하도록 특별한 방식으로 표시됩니다.

// 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!") }

Compose의 접근성에 관한 자세한 내용은 Compose의 접근성을 참고하세요.

수정자를 사용하여 임의의 컴포저블에 특정 동작 추가

동작 수정자를 임의의 컴포저블에 적용하여 컴포저블이 동작을 수신 대기하도록 할 수 있습니다. 예를 들어 일반 Boxclickable로 만들어 탭 동작을 처리하도록 하거나 verticalScroll을 적용하여 Column에서 세로 스크롤을 처리하도록 할 수 있습니다.

다양한 유형의 동작을 처리할 수 있는 여러 수정자가 있습니다.

일반적으로 맞춤 동작 처리보다 즉시 사용 가능한 동작 수정자를 사용하는 것이 좋습니다. 수정자는 순수 포인터 이벤트 처리 외에도 더 많은 기능을 추가합니다. 예를 들어 clickable 수정자는 누르기 및 탭 감지를 추가할 뿐만 아니라 시맨틱 정보, 상호작용에 관한 시각적 표시, 마우스 오버, 포커스, 키보드 지원도 추가합니다. clickable의 소스 코드에서 기능이 어떻게 추가되는지 확인할 수 있습니다.

pointerInput 수정자를 사용하여 임의의 컴포저블에 맞춤 동작 추가

모든 동작이 즉시 사용 가능한 동작 수정자로 구현되는 것은 아닙니다. 예를 들어 길게 누르기, Ctrl + 클릭 또는 세 손가락으로 탭한 후 드래그에 반응하는 데는 수정자를 사용할 수 없습니다. 대신 자체 동작 핸들러를 작성하여 이러한 맞춤 동작을 식별할 수 있습니다. 원시 포인터 이벤트에 액세스할 수 있는 pointerInput 수정자를 사용하여 동작 핸들러를 만들 수 있습니다.

다음 코드는 원시 포인터 이벤트를 리슨합니다.

@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 수정자 하나 이상의 를 전달합니다. 이러한 키 중 하나의 값이 변경되면 특수키 콘텐츠 람다가 다시 실행됩니다. 이 샘플은 컴포저블에 선택적 필터를 전달합니다. 필터 값이 변경되면 올바른 이벤트가 로깅되도록 포인터 이벤트 핸들러를 다시 실행해야 합니다.
  • awaitPointerEventScope는 포인터 이벤트를 기다리는 데 사용할 수 있는 코루틴 범위를 만듭니다.
  • awaitPointerEvent는 다음 포인터 이벤트가 발생할 때까지 코루틴을 정지합니다.

원시 입력 이벤트를 수신 대기하는 것도 강력하지만, 이 원시 데이터를 기반으로 맞춤 동작을 작성하는 것도 복잡합니다. 맞춤 동작을 간단하게 만들 수 있도록 여러 유틸리티 메서드를 사용할 수 있습니다.

전체 동작 감지

원시 포인터 이벤트를 처리하는 대신 특정 동작이 발생하고 적절하게 반응하도록 수신 대기할 수 있습니다. AwaitPointerEventScope는 다음을 수신 대기하는 메서드를 제공합니다.

이는 최상위 수준 감지기이므로 하나의 pointerInput 수정자 내에 여러 감지기를 추가할 수 없습니다. 다음 스니펫은 드래그가 아닌 탭만 감지합니다.

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 메서드는 코루틴을 차단하며 두 번째 감지기에는 도달하지 않습니다. 컴포저블에 동작 리스너를 두 개 이상 추가해야 한다면 별도의 pointerInput 수정자 인스턴스를 대신 사용하세요.

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" }
            }
    )
}

동작당 이벤트 처리

동작은 정의상 포인터 아래로 이벤트에서 시작됩니다. 각 원시 이벤트를 통과하는 while(true) 루프 대신 awaitEachGesture 도우미 메서드를 사용할 수 있습니다. 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를 사용하는 것이 좋습니다. 이에 대한 예로 hoverable를 들 수 있습니다. 이 메서드는 포인터 아래 또는 위 이벤트에 응답하지 않으며 포인터가 경계에 들어가거나 나올 때를 알아야 합니다.

특정 이벤트 또는 하위 동작 대기

동작의 일반적인 부분을 식별하는 데 도움이 되는 일련의 메서드가 있습니다.

멀티터치 이벤트에 계산 적용

사용자가 포인터를 두 개 이상 사용하여 멀티 터치 동작을 실행하는 경우 원시 값을 기반으로 필요한 변환을 이해하는 것이 복잡합니다. transformable 수정자 또는 detectTransformGestures 메서드가 사용 사례에 관한 세밀한 제어를 충분히 제공하지 않는 경우 원시 이벤트를 수신 대기하고 이에 대해 계산을 적용할 수 있습니다. 이러한 도우미 메서드는 calculateCentroid, calculateCentroidSize, calculatePan, calculateRotation, calculateZoom입니다.

이벤트 디스패치 및 히트 테스트

모든 포인터 이벤트가 모든 pointerInput 수정자로 전송되는 것은 아닙니다. 이벤트 전달은 다음과 같이 작동합니다.

  • 포인터 이벤트는 구성 가능한 계층 구조로 전달됩니다. 새 포인터가 첫 번째 포인터 이벤트를 트리거하는 순간 시스템은 '적격한' 컴포저블의 히트 테스트를 시작합니다. 컴포저블은 포인터 입력 처리 기능이 있는 경우 적합한 것으로 간주됩니다. 히트 테스트는 UI 트리의 상단에서 하단까지 진행됩니다. 컴포저블은 포인터 이벤트가 컴포저블의 경계 내에서 발생하면 '조회'가 됩니다. 이 프로세스로 인해 히트 테스트를 실행하는 컴포저블 체인이 생성됩니다.
  • 기본적으로 트리의 동일한 수준에 사용 가능한 컴포저블이 여러 개 있는 경우 Z-색인이 가장 높은 컴포저블만 '조회'가 됩니다. 예를 들어 겹치는 Button 컴포저블 두 개를 Box에 추가하면 위에 그려진 컴포저블만 포인터 이벤트를 수신합니다. PointerInputModifierNode 구현을 만들고 sharePointerInputWithSiblings를 true로 설정하여 이 동작을 이론적으로 재정의할 수 있습니다.
  • 동일한 포인터의 추가 이벤트는 동일한 컴포저블 체인으로 전달되고 이벤트 전파 로직에 따라 흐릅니다. 시스템은 이 포인터에 관해 더 이상 히트 테스트를 실행하지 않습니다. 즉, 체인에 있는 각 컴포저블은 포인터의 모든 이벤트를 수신하며, 이는 컴포저블의 경계 외부에서 발생한 경우에도 마찬가지입니다. 체인에 없는 컴포저블은 포인터가 경계 내에 있는 경우에도 포인터 이벤트를 수신하지 않습니다.

마우스 또는 스타일러스 마우스 오버로 트리거되는 마우스 오버 이벤트는 여기에 정의된 규칙의 예외입니다. 마우스 오버 이벤트는 도달한 모든 컴포저블로 전송됩니다. 따라서 사용자가 한 컴포저블의 경계에서 다른 컴포저블로 포인터를 가져가면 이벤트를 첫 번째 컴포저블로 전송하는 대신 이벤트가 새 컴포저블로 전송됩니다.

이벤트 소비

둘 이상의 컴포저블에 동작 핸들러가 할당된 경우 이러한 핸들러는 충돌해서는 안 됩니다. 예를 들어 다음 UI를 살펴보세요.

이미지, 텍스트가 두 개 있는 열, 버튼이 있는 목록 항목

사용자가 북마크 버튼을 탭하면 버튼의 onClick 람다가 이 동작을 처리합니다. 사용자가 목록 항목의 다른 부분을 탭하면 ListItem는 해당 동작을 처리하고 기사로 이동합니다. 포인터 입력 측면에서 버튼은 상위 요소가 더 이상 이벤트에 반응하지 않도록 알 수 있도록 이 이벤트를 사용해야 합니다. 즉시 사용 가능한 구성요소와 일반적인 동작 수정자에 포함된 동작에 이러한 사용 동작이 포함되지만, 맞춤 동작을 직접 작성하는 경우에는 이벤트를 수동으로 사용해야 합니다. 이 작업은 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
            }
        }
    }
}

이벤트 전파

앞서 언급했듯이 포인터 변경사항은 적용되는 각 컴포저블에 전달됩니다. 하지만 이러한 컴포저블이 두 개 이상 있는 경우 이벤트는 어떤 순서로 전파되나요? 마지막 섹션의 예를 들면 이 UI는 다음과 같은 UI 트리로 변환됩니다. 여기서 ListItemButton만 포인터 이벤트에 응답합니다.

트리 구조 상단 레이어는 ListItem이고, 두 번째 레이어에는 Image, Column, Button이 있으며 Column은 두 개의 Text로 분할됩니다. ListItem 및 Button이 강조표시됨

포인터 이벤트는 세 번의 '패스' 동안 이러한 각 컴포저블을 통해 세 번 흐릅니다.

  • 초기 패스에서 이벤트는 UI 트리의 상단에서 하단으로 진행됩니다. 이 흐름을 통해 상위 요소는 하위 요소가 이벤트를 소비하기 전에 가로챌 수 있습니다. 예를 들어 도움말은 하위 요소에 전달하는 대신 길게 누르기를 가로채야 합니다. 이 예에서 ListItemButton 전에 이벤트를 수신합니다.
  • 기본 패스에서 이벤트는 UI 트리의 리프 노드에서 UI 트리 루트까지 진행됩니다. 이 단계에서는 일반적으로 동작을 사용하며, 이벤트를 수신할 때 이 단계가 기본 패스입니다. 이 패스에서 동작을 처리하면 리프 노드가 상위 요소보다 우선하며, 이는 대부분의 동작에서 가장 논리적인 동작입니다. 이 예에서 ButtonListItem 이전의 이벤트를 수신합니다.
  • 최종 패스에서는 이벤트가 UI 트리 상단에서 리프 노드로 한 번 더 흐릅니다. 이 흐름을 통해 스택에서 상위 요소가 상위 요소의 이벤트 소비에 응답할 수 있습니다. 예를 들어 버튼은 버튼을 눌렀다가 스크롤 가능한 상위 요소의 드래그로 전환되면 물결 효과 표시를 삭제합니다.

이벤트 흐름을 시각적으로 다음과 같이 나타낼 수 있습니다.

입력 변경이 소비되면 흐름의 해당 지점부터 이 정보가 전달됩니다.

코드에서 관심 있는 패스를 지정할 수 있습니다.

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

이 코드 스니펫에서는 각 대기 메서드 호출에서 동일한 이벤트가 반환되지만, 소비에 대한 데이터가 변경되었을 수도 있습니다.

동작 테스트

테스트 메서드에서 performTouchInput 메서드를 사용하여 포인터 이벤트를 수동으로 전송할 수 있습니다. 이를 통해 높은 수준의 전체 동작 (예: 손가락 모으기 또는 길게 클릭)이나 낮은 수준의 동작 (예: 특정 픽셀만큼 커서 이동)을 실행할 수 있습니다.

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

더 많은 예는 performTouchInput 문서를 참고하세요.

자세히 알아보기

Jetpack Compose의 동작에 관한 자세한 내용은 다음 리소스를 참고하세요.