Android

[프론트엔드 개발자를 위한 Android] 제스처 이벤트 감지하기

Jaymyong66 2025. 10. 31. 13:43

클릭 제스처

탭이라고도 하는 클릭 제스처는 modifier로 보이는 모든 컴포저블에서 감지 가능하다.

SomeComposable(
    modifier = Modifier.clickable { }
)

참고) selectable : clickable과 비슷한 동작이지만 UI 접근성 측면에서 이점이 있다. 내부 구현은 clickable이다.

Modifier
    .selectable(
       selected = true or false, // state
       onClick = { },
    )

하지만 탭, 더블 탭, 프레스, 롱 프레스를 구분하려면 PointerInputScope 를 활용해야한다.

제스처는 다음과 같이 진행된다.

  • press -> long press
  • press -> tab(터치 해제)
  • press -> double tap
Box(  
    Modifier  
        .pointerInput(Unit) {  
            detectTapGestures(  
                onPress = { tapHandler("onPress Detected") },  
                onDoubleTap = { tapHandler("onDoubleTap Detected") },  
                onLongPress = { tapHandler("onLongPress Detected") },  
                onTap = { tapHandler("onTap Detected") }  
            )  
        }  
)

참고) 만약 더블탭이나 롱프레스의 시간을 바꾸고 싶다면 ViewConfiguration을 커스텀하여 가능하다.

private class CustomViewConfiguration : ViewConfiguration {  
    override val doubleTapMinTimeMillis: Long  
        get() = 1000  
    override val doubleTapTimeoutMillis: Long  
        get() = 2000  
    override val longPressTimeoutMillis: Long  
        get() = 3000  
    override val touchSlop: Float  
        get() = 8f  
}

CompositionLocalProvider(  
    LocalViewConfiguration provides CustomViewConfiguration()  
) {
    // ...
}

드래그 제스처

modifier에 draggable() 을 적용한다.
움직임 시작 위치로부터 오프셋을 상태로 저장한다. rememberDraggableState()
하지만 수평 혹은 수직으로의 드래그 제스처 구현에만 유용하다. 여러 방향은 PointerInputScope의 detectDragGestures 함수를 이용한다.

var xOffset by remember { mutableStateOf(0f) }  

Box(  
    modifier = Modifier  
        .offset { IntOffset(xOffset.roundToInt(), 0) }  
        .size(100.dp)  
        .background(Color.Blue)  
        .draggable(  
            orientation = Orientation.Horizontal,  
            state = rememberDraggableState { distance ->  
                xOffset += distance  
            }  
        )  
)

rememberDraggableState 같은 경우에는 델타값을 넘겨주어, 해당 값(distance)에 *2 를 하는 등의 조작도 가능하다.

@Composable  
fun rememberDraggableState(onDelta: (Float) -> Unit): DraggableState {  
    val onDeltaState = rememberUpdatedState(onDelta)  
    return remember { DraggableState { onDeltaState.value.invoke(it) } }  
}

만약 수직/수평 동시 드래그 조작을 원한다면 pointerInput의 detectDragGestures로 구현 가능하다.
detectDragGestures의 마지막 람다의 두번째 객체가 Offset 객체이다. 이전과 비슷하게 움직일 사물의 오프셋을 상태로 두고 이를 변화시킨다.

var xOffset by remember { mutableStateOf(0f) }  
var yOffset by remember { mutableStateOf(0f) }  

Box(  
    Modifier  
        .offset { IntOffset(xOffset.roundToInt(), yOffset.roundToInt()) }  
        .background(Color.Blue)  
        .size(100.dp)  
        .pointerInput(Unit) {  
            detectDragGestures { _, distance ->  
                xOffset += distance.x  
                yOffset += distance.y  
            }  
    }
)

detectDragGestures 에는 onDragStart, onDragEnd 등의 콜백이 있어 다양하게 활용 가능할 것 같다.

suspend fun PointerInputScope.detectDragGestures(  
    onDragStart: (Offset) -> Unit = { },  
    onDragEnd: () -> Unit = { },  
    onDragCancel: () -> Unit = { },  
    onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit  
) {

스크롤 제스처

modifier에 scrollable로 orientation을 지정하여 추가 가능하다.
하지만 이는 단방향 지정이 가능하며, 만약 수평/수직을 모두 감지하려면 2개의 modifier를 함께 사용해야한다.

Box(  
    Modifier  
        .fillMaxSize()  
        .scrollable(  
            orientation = Orientation.Vertical,  
            state = rememberScrollableState { distance ->  
                // ... do something
                offset += distance  
                distance
            }  
        )  
)
Box(  
    modifier = Modifier  
        .size(150.dp)  
        .verticalScroll(rememberScrollState())  
        .horizontalScroll(rememberScrollState())  
)

멀티터치(꼬집기) 제스처

핀치줌 같은 기능을 만들때 유용할 것 같다.
modifier의 transformable 을 활용하여 rememberTransformableState를 주입해 scale, angle, offset을 컨트롤 가능하다.
예시는 이 state를 이용해 graphicsLayer로 조절한다.

    var scale by remember { mutableStateOf(1f) }
    var angle by remember { mutableStateOf(0f) }
    var offset by remember { mutableStateOf(Offset.Zero) }

    val state = rememberTransformableState { scaleChange, offsetChange, rotationChange ->
        scale *= scaleChange
        angle += rotationChange
        offset += offsetChange
    }

    Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) {
        Box(
            Modifier
                .graphicsLayer(
                    scaleX = scale,
                    scaleY = scale,
                    rotationZ = angle,
                    translationX = offset.x,
                    translationY = offset.y
                )
                .transformable(state = state)
                .background(Color.Blue)
                .size(100.dp)
        )
    } 

49. 스와이프 제스처 감지하기

swipeable API는 deprecated 되었고 anchoredDraggable API를 쓰라고 한다.

스와이프 제스처란, 기기 화면에 접촉한 지점에서의 수직 / 수평 움직임을 의미한다.
예를 들어, "밀어서 잠금 해제" 같은 제스처이다.

modifier에 swipeable()을 호출한다. 이를 위한 필수 파라미터 코드이다.
Column 범위를 스와이프 가능한 컴포저블로 만든 예시이다.

val state = rememberSwipeableState("On")  
val anchors = mapOf(0f to "On", 100f to "Off", 200f to "Locked")

Column(  
    modifier = Modifier.swipeable(  
        state = state,  
        anchors = anchors,  
        thresholds = {_, _ -> FractionalThreshold(0.5f)},
        orientation = Orientation.Horizontal  
    )
)

먼저 state 에는 rememberSwipeableState이 사용되며, offset, currentValue, targetValue 등의 값이 포함된다. 초기값을 설정할 수 있는데, 이는 anchors Map의 value와 같은 타입이어야 한다.

anchors는 Map인데, key는 float 타입의 px 단위이며, value 는 state값이다. 스와이프 제스처를 몇 px만큼 하냐의 기준이 되는 지점이며, 그 지점에 해당하는 state가 매핑된다.

state에는 currentValue, targetValue가 있는데, currentValue에서 thresholds를 넘는다면 targetValue로 state이 변하게 된다. (anchor의 pixel 좌표와 state의 value를 잘 구분해야한다)

thresholds는 각 anchor 사이의 임계치를 설정한다. 두 가지 threshold가 있는데, 하나는 비율로 계산하는 FractionalThreshold이다. 위 코드 예시를 들면, 0px에서 100px이 되려면 둘 사이에서 50%가 넘게 스와이프 해야한다. 만약 스와이프를 49 px 만큼 한다면, 임계치를 못 넘었기에 state는 여전히 "On"이며 anchor value는 다시 0px로 돌아갈 것이다.
또 다른 threshold는 FixedThreshold로 임계치를 고정값으로 설정한다. 아래와 같이 설정할 수 있다.

thresholds = {_, _ -> FixedThreshold(10.dp)},

이는 현재 좌표에서 10.dp 만큼 스와이프 한다면 targetValue로 state이 바뀐다는 것이다.

마지막 orientation은 스와이프의 방향을 설정한다.


특이했던 점은, anchors로 설정한 값 범위의 바깥 양끝으로 20%만큼 추가로 스와이프 할 수 있다.
물론 anchors 범위 밖으로 스와이프는 가능하지만 state 변화 없이 다시 범위 내부로 들어온다.
아마도 스와이프의 자연스러운 인터랙션을 구현하기 위함이 아닌가 싶다.

(20%는 실험을 통해 확인한 값이다. 예를 들어 범위가 0f ~ 500f 면 양끝으로 +- 50px 더 스와이프 가능하다.)

anchoredDraggableswipeable 대체하기

To be continue..