애플리케이션에서 동작 처리를 작업할 때 이해해야 하는 몇 가지 중요한 용어와 개념이 있습니다. 이 페이지에서는 포인터, 포인터 이벤트, 동작이라는 용어를 설명하고 동작의 다양한 추상화 수준을 소개합니다. 또한 이벤트 소비 및 전파에 대해서도 자세히 알아봅니다.
정의
이 페이지의 다양한 개념을 이해하려면 사용되는 몇 가지 용어를 이해해야 합니다.
- 포인터: 애플리케이션과 상호작용하는 데 사용할 수 있는 실제 객체입니다.
휴대기기의 경우 가장 일반적인 포인터는 터치 스크린과 상호작용하는 손가락입니다. 또는 손가락 대신 스타일러스를 사용할 수 있습니다.
대형 화면의 경우 마우스나 트랙패드를 사용하여 디스플레이와 간접적으로 상호작용할 수 있습니다. 입력 장치는 좌표를 '가리킬' 수 있어야 포인터로 간주되므로 키보드는 포인터로 간주될 수 없습니다. Compose에서 포인터 유형은
PointerType를 사용하여 포인터 변경사항에 포함됩니다. - 포인터 이벤트: 특정 시점에 애플리케이션과 하나 이상의 포인터의 하위 수준 상호작용을 설명합니다. 화면에 손가락을 대거나 마우스를 드래그하는 등의 포인터 상호작용은 이벤트를 트리거합니다. Compose에서 이러한 이벤트와 관련된 모든 정보는
PointerEvent클래스에 포함됩니다. - 동작: 단일 작업으로 해석할 수 있는 포인터 이벤트 시퀀스입니다. 예를 들어 탭 동작은 다운 이벤트와 그 뒤에 이어지는 업 이벤트의 시퀀스로 간주할 수 있습니다. 탭, 드래그, 변환과 같이 많은 앱에서 사용하는 일반적인 동작이 있지만 필요한 경우 자체 맞춤 동작을 만들 수도 있습니다.
다양한 추상화 수준
Jetpack Compose는 동작 처리를 위한 다양한 수준의 추상화를 제공합니다.
최상위 수준은 구성요소 지원입니다. Button과 같은 컴포저블에는 동작 지원이 자동으로 포함됩니다. 맞춤 구성요소에 동작 지원을 추가하려면 clickable와 같은 동작 수정자를 임의의 컴포저블에 추가하면 됩니다. 마지막으로, 맞춤 동작이 필요한 경우 pointerInput 수정자를 사용할 수 있습니다.
일반적으로 필요한 기능을 제공하는 가장 높은 수준의 추상화를 기반으로 빌드합니다. 이렇게 하면 레이어에 포함된 권장사항을 활용할 수 있습니다. 예를 들어 Button에는 접근성에 사용되는 시맨틱 정보가 clickable보다 더 많이 포함되어 있으며,
에는 원시
pointerInput 구현보다 더 많은 정보가 포함되어 있습니다.
구성요소 지원
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의 접근성을 참고하세요.
수정자를 사용하여 임의의 컴포저블에 특정 동작 추가
임의의 컴포저블에 동작 수정자를 적용하여 컴포저블이 동작을 수신하도록 할 수 있습니다. 예를 들어 일반 Box를 clickable로 만들어 탭 동작을 처리하도록 하거나 verticalScroll를 적용하여 Column가 세로 스크롤을 처리하도록 할 수 있습니다.
다양한 유형의 동작을 처리하는 수정자가 많습니다.
clickable,combinedClickable,selectable,toggleable,triStateToggleable수정자를 사용하여 탭 및 누르기를 처리합니다.horizontalScroll,verticalScroll, 더 일반적인scrollable수정자를 사용하여 스크롤을 처리합니다.draggable및swipeable수정자를 사용하여 드래그를 처리합니다.transformable수정자를 사용하여 화면 이동, 회전, 확대/축소와 같은 멀티 터치 동작을 처리합니다.
일반적으로 커스텀 동작 처리보다는 즉시 사용 가능한 동작 수정자를 사용하는 것이 좋습니다.
수정자는 순수 포인터 이벤트 처리에 더 많은 기능을 추가합니다.
예를 들어 clickable 수정자는 누르기 및 탭 감지를 추가할 뿐만 아니라 시맨틱 정보, 상호작용에 관한 시각적 표시, 마우스 오버, 포커스, 키보드 지원도 추가합니다. clickable의 소스 코드를 확인하여 기능이 어떻게 추가되는지 확인할 수 있습니다.
pointerInput 수정자를 사용하여 임의의 컴포저블에 맞춤 동작 추가
모든 동작이 기본 동작 수정자로 구현되는 것은 아닙니다. 예를 들어 수정자를 사용하여 길게 누르기 후 드래그, 컨트롤 클릭 또는 세 손가락 탭에 반응할 수 없습니다. 대신 자체 동작 핸들러를 작성하여 이러한 맞춤 동작을 식별할 수 있습니다. 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는 다음을 수신 대기하는 메서드를 제공합니다.
- 누르기, 탭하기, 두 번 탭하기, 길게 누르기:
detectTapGestures - 드래그:
detectHorizontalDragGestures,detectVerticalDragGestures,detectDragGestures,detectDragGesturesAfterLongPress - 변환:
detectTransformGestures
이러한 감지기는 최상위 감지기이므로 하나의 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는 포인터 다운 또는 업 이벤트에 응답하지 않으며 포인터가 경계에 들어가거나 나갈 때만 알면 됩니다.
특정 이벤트 또는 하위 동작 대기
동작의 공통 부분을 식별하는 데 도움이 되는 메서드 집합이 있습니다.
awaitFirstDown를 사용하여 포인터가 내려갈 때까지 정지하거나waitForUpOrCancellation를 사용하여 모든 포인터가 올라갈 때까지 기다립니다.awaitTouchSlopOrCancellation및awaitDragOrCancellation를 사용하여 하위 수준 드래그 리스너를 만듭니다. 제스처 핸들러는 먼저 포인터가 터치 슬롭에 도달할 때까지 정지한 다음 첫 번째 드래그 이벤트가 발생할 때까지 정지합니다. 단일 축을 따라 드래그하는 데만 관심이 있다면 대신awaitHorizontalTouchSlopOrCancellation과awaitHorizontalDragOrCancellation또는awaitVerticalTouchSlopOrCancellation과awaitVerticalDragOrCancellation를 사용하세요.awaitLongPressOrCancellation를 길게 누를 때까지 정지합니다.drag메서드를 사용하여 드래그 이벤트를 지속적으로 수신 대기하거나,horizontalDrag또는verticalDrag를 사용하여 한 축에서 드래그 이벤트를 수신 대기합니다.
멀티 터치 이벤트에 계산 적용
사용자가 두 개 이상의 포인터를 사용하여 멀티 터치 동작을 실행하는 경우 원시 값을 기반으로 필요한 변환을 이해하기가 복잡합니다.
transformable 수정자 또는 detectTransformGestures 메서드가 사용 사례에 충분히 세분화된 제어를 제공하지 않는 경우 원시 이벤트를 수신 대기하고 이에 계산을 적용할 수 있습니다. 이러한 도우미 메서드는 calculateCentroid, calculateCentroidSize, calculatePan, calculateRotation, calculateZoom입니다.
이벤트 전달 및 히트 테스트
모든 포인터 이벤트가 모든 pointerInput 수정자에 전송되는 것은 아닙니다. 이벤트 전달은 다음과 같이 작동합니다.
- 포인터 이벤트는 구성 가능한 계층 구조로 전달됩니다. 새 포인터가 첫 번째 포인터 이벤트를 트리거하는 순간 시스템은 '적합한' 컴포저블의 Hit Test를 시작합니다. 포인터 입력 처리 기능이 있는 컴포저블은 적합한 것으로 간주됩니다. Hit Test는 UI 트리의 상단에서 하단으로 진행됩니다. 포인터 이벤트가 컴포저블의 경계 내에서 발생하면 컴포저블이 '적중'됩니다. 이 프로세스는 Hit Test를 긍정적으로 수행하는 구성 가능한 함수의 체인을 생성합니다.
- 기본적으로 트리의 동일한 수준에 적격한 컴포저블이 여러 개 있는 경우 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 트리로 변환되며, 여기서 ListItem 및 Button만 포인터 이벤트에 응답합니다.
포인터 이벤트는 세 번의 '패스' 동안 이러한 각 컴포저블을 세 번 통과합니다.
- 초기 패스에서 이벤트는 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) } }
이 코드 스니펫에서는 소비에 관한 데이터가 변경되었을 수 있지만 이러한 각 await 메서드 호출에서 동일한 이벤트가 반환됩니다.
동작 테스트
테스트 메서드에서 performTouchInput 메서드를 사용하여 포인터 이벤트를 수동으로 전송할 수 있습니다. 이렇게 하면 상위 수준의 전체 동작 (예: 핀치 또는 길게 클릭) 또는 하위 수준의 동작 (예: 특정 픽셀 수만큼 커서 이동)을 실행할 수 있습니다.
composeTestRule.onNodeWithTag("MyList").performTouchInput { swipeUp() swipeDown() click() }
자세한 내용은 performTouchInput 문서를 참고하세요.
자세히 알아보기
Jetpack Compose의 동작에 관한 자세한 내용은 다음 리소스를 참고하세요.
추천 서비스
- 참고: JavaScript가 사용 중지되어 있으면 링크 텍스트가 표시됩니다.
- Compose의 접근성
- 스크롤
- I can't translate this text.