드래그 컴포저블

사용자가 화면에서 구성요소를 물리적으로 드래그할 수 있도록 하는 것은 최신 Android 앱의 기본입니다. 맞춤 슬라이더, 스와이프하여 닫기 구성요소, 대화형 캔버스 등 무엇을 빌드하든 Jetpack Compose는 드래그 이벤트를 쉽게 처리할 수 있는 포괄적인 API 집합을 제공합니다.

Compose 드래그 API는 원시 터치 이벤트의 복잡성을 추상화합니다. pointerInput를 사용하여 터치 좌표를 수동으로 계산하고 동작 수명 주기를 관리하는 대신 Compose의 상태 관리 및 애니메이션 시스템에 직접 연결되는 내장 수정자를 사용할 수 있습니다.

이 페이지에서는 각 드래그 API를 사용하는 방법과 시기를 설명합니다.

올바른 드래그 API 선택

드래그 가능한 콘텐츠를 구현하는 데 사용할 수 있는 API가 여러 개 있습니다.

  • 다음과 같은 경우 표준 Material 구성요소 (예: SwipeToDismissBox 또는 탐색 창)를 선택합니다. 기본 Material 3 스와이프 또는 드래그 동작 (예: 이메일 삭제, 작업 보관처리, 측면 메뉴 / 하단 시트 열기)이 필요한 경우 맞춤 상태나 레이아웃 관리가 필요하지 않은 기본 제공 동작을 제공합니다.
  • Modifier.draggable를 선택하는 경우: 원시 드래그 델타만 필요한 경우 (예: 맞춤 슬라이더 드래그) 자세한 내용은 드래그 가능을 사용하여 단일 축을 따라 드래그를 참고하세요.
  • Modifier.anchoredDraggable을 선택하는 경우: 두 개 이상의 개별 위치 간에 스냅해야 하는 맞춤 대화형 구성요소 (예: 하단 시트, 탐색 창, 스와이프하여 표시 작업)를 빌드하는 경우 자세한 내용은 anchoredDraggable를 사용하여 앵커 포인트 간에 구성요소 드래그를 참고하세요.

draggable 키를 누른 상태에서 하나의 축을 따라 드래그합니다.

draggable 수정자는 동작을 한 방향으로 드래그하는 상위 수준 진입점이며 드래그 거리를 픽셀 단위로 보고합니다.

이 수정자는 동작만 감지한다는 점에서 scrollable과 유사합니다. 예를 들어 offset 수정자를 통해 요소를 이동하여 상태를 유지하고 화면에 표시해야 합니다.

@Composable
private fun DraggableText() {
    var offsetX by remember { mutableFloatStateOf(0f) }
    Text(
        modifier = Modifier
            .offset { IntOffset(offsetX.roundToInt(), 0) }
            .draggable(
                orientation = Orientation.Horizontal,
                state = rememberDraggableState { delta ->
                    offsetX += delta
                }
            ),
        text = "Drag me!"
    )
}

전체 드래그 동작을 제어해야 하는 경우 대신 pointerInput 수정자를 통해 드래그 동작 감지기를 사용해 보세요.

@Composable
private fun DraggableTextLowLevel() {
    Box(modifier = Modifier.fillMaxSize()) {
        var offsetX by remember { mutableFloatStateOf(0f) }
        var offsetY by remember { mutableFloatStateOf(0f) }

        Box(
            Modifier
                .offset { IntOffset(offsetX.roundToInt(), offsetY.roundToInt()) }
                .background(Color.Blue)
                .size(50.dp)
                .pointerInput(Unit) {
                    detectDragGestures { change, dragAmount ->
                        change.consume()
                        offsetX += dragAmount.x
                        offsetY += dragAmount.y
                    }
                }
        )
    }
}

여기에 대체 텍스트 삽입
그림 1. 손가락으로 드래그 중인 UI 요소

anchoredDraggable 키를 사용하여 기준점 간에 구성요소 드래그

anchoredDraggable 수정자를 사용하면 앵커 포인트라고 하는 사전 정의된 위치 집합 간에 구성요소를 드래그할 수 있습니다. 사용자가 드래그 동작을 해제하면 구성요소가 가장 가까운 앵커에 고정됩니다. anchoredDraggable은 커스텀 하단 시트, 복잡한 슬라이더, 드로어 패널과 같은 구성요소를 만드는 데 유용합니다.

API 노출 영역

AnchoredDraggable API는 다음과 같은 주요 부분으로 구성됩니다.

  • 앵커 (DraggableAnchors): 맞춤 상태 (예: Start, Center, End의 enum 클래스)와 이에 상응하는 픽셀 오프셋 값 간의 매핑을 정의하는 빌더입니다.
  • 상태 (AnchoredDraggableState<T>): 구성요소의 현재 위치 (오프셋), 고정된 앵커, 드래그 진행 상황을 관리하는 상태 홀더입니다. 구성 변경 시 상태를 유지하려면 rememberSaveable 및 AnchoredDraggableState.Saver<T>를 사용하여 만들어야 합니다.
  • 동작 수정자 (Modifier.anchoredDraggable): Orientation를 따라 드래그 동작을 감지하고 드래그 델타로 상태를 업데이트하며 플링 동작을 처리하여 손을 뗄 때 구성요소를 가장 가까운 앵커로 애니메이션 처리하는 수정자입니다.

예시: 커스텀 SwipeToDismiss 구성요소 빌드

이 예에서는 사용자가 왼쪽으로 스와이프하여 닫거나 오른쪽으로 드래그하여 오버스크롤 늘이기 효과를 볼 수 있는 맞춤 SwipeToDismiss 구성요소 (흰색 배너)를 빌드하는 방법을 보여줍니다.

  1. 기준값 정의 드래그 가능한 콘텐츠가 정착할 수 있는 개별 위치를 나타내는 enum을 정의합니다.

    private enum class AnchoredDraggableSwipeDismissValue { DismissedStart, Resting }

  2. AnchoredDraggableState를 인스턴스화하고 저장합니다. AnchoredDraggableState.Saver()와 함께 rememberSaveable를 사용하여 활동 재생성 및 구성 변경 (예: 화면 회전 또는 테마 변경) 전반에서 구성요소의 현재 위치를 유지합니다.

    val state =
        rememberSaveable(saver = AnchoredDraggableState.Saver()) {
            AnchoredDraggableState(initialValue = AnchoredDraggableSwipeDismissValue.Resting)
        }

  3. layout, offset, draggable 수정자를 적용합니다. 구성요소를 드래그 가능하게 만들고 앵커 포인트가 크기에 맞게 조정되도록 하려면 시각적으로 이동해야 하는 콘텐츠에 다음 수정자를 적용합니다.

    • Modifier.layout: 앵커 위치는 구성요소의 크기에 따라 달라지는 경우가 많으며, 이는 초기 컴포지션 중에 알 수 없습니다. layout 수정자를 사용하여 구성요소를 측정하고, 너비에 따라 DraggableAnchors 맵을 동적으로 계산하고, state.updateAnchors()를 사용하여 상태를 업데이트합니다.

    • Modifier.anchoredDraggable: 상태에 연결하고, 지정된 방향으로 드래그 델타를 전달하고, 손을 떼면 구성요소를 가장 가까운 앵커로 애니메이션 처리하는 플링 동작을 처리합니다. 드래그 동작에 응답해야 하는 구성요소에 이 수정자를 추가하고 state 및 드래그 orientation (선택사항으로 overscrollEffect)를 전달합니다.
    • Modifier.offset: 오프셋 람다 내에서 state.requireOffset()의 오프셋을 읽습니다. 이 람다 내에서 오프셋을 읽으면 배치 단계에서만 값이 읽히므로 드래그 중에 불필요한 리컴포지션이 방지됩니다. 시각적으로 이동해야 하는 콘텐츠에 이 수정자를 적용합니다.

    Box(
        modifier = Modifier
            .fillMaxWidth()
            // Read state.requireOffset() inside the offset lambda to defer reading to the placement phase
            .offset { IntOffset(state.requireOffset().roundToInt(), 0) }
            .layout { measurable, constraints ->
                val placeable = measurable.measure(constraints)
                val anchors = DraggableAnchors {
                    AnchoredDraggableSwipeDismissValue.DismissedStart at -placeable.width.toFloat()
                    AnchoredDraggableSwipeDismissValue.Resting at 0f
                }
                state.updateAnchors(anchors)
                layout(placeable.width, placeable.height) { placeable.placeRelative(0, 0) }
            }
            // Pass overscrollEffect to anchoredDraggable so deltas beyond the bounds (min and max anchor) are dispatched to overscrollEffect, and render the visual effect with Modifier.overscroll.
            .overscroll(overscrollEffect)
            .anchoredDraggable(
                state,
                Orientation.Horizontal,
                overscrollEffect = overscrollEffect
            )
    ) {
        dismissContent()
    }

  4. [선택사항] 오버스크롤을 통합합니다. overscrollEffect API는 스크롤 또는 플링으로 스크롤 컨테이너의 가장자리에 도달했을 때 표시되는 시각 효과를 구현합니다. 사용자가 오버스크롤 효과 (일반적으로 늘이기 또는 빛나기)를 사용하여 정의된 앵커 경계를 지나 드래그할 때 시각적 피드백을 제공할 수 있습니다.

    1. 전달: overscrollEffect 인스턴스를 anchoredDraggable 수정자에 전달합니다. 이렇게 하면 동작이 앵커 경계 내부에 있는지 외부에 있는지에 따라 드래그 델타가 AnchoredDraggable 상태 또는 오버스크롤 효과로 전달됩니다.
    2. 렌더링: 동일한 OverscrollEffect 인스턴스를 사용하여 Modifier.overscroll를 효과를 시각적으로 렌더링해야 하는 구성요소에 적용합니다.

다음 스니펫은 표준 늘이기 효과를 추가하는 방법을 보여줍니다.

val overscrollEffect = rememberOverscrollEffect()
Box(
    modifier.fillMaxWidth()
) {
    Box(Modifier.fillMaxWidth()) {
        background()
    }
    Box(
        modifier = Modifier
            .fillMaxWidth()
            // Read state.requireOffset() inside the offset lambda to defer reading to the placement phase
            .offset { IntOffset(state.requireOffset().roundToInt(), 0) }
            .layout { measurable, constraints ->
                val placeable = measurable.measure(constraints)
                val anchors = DraggableAnchors {
                    AnchoredDraggableSwipeDismissValue.DismissedStart at -placeable.width.toFloat()
                    AnchoredDraggableSwipeDismissValue.Resting at 0f
                }
                state.updateAnchors(anchors)
                layout(placeable.width, placeable.height) { placeable.placeRelative(0, 0) }
            }
            // Pass overscrollEffect to anchoredDraggable so deltas beyond the bounds (min and max anchor) are dispatched to overscrollEffect, and render the visual effect with Modifier.overscroll.
            .overscroll(overscrollEffect)
            .anchoredDraggable(
                state,
                Orientation.Horizontal,
                overscrollEffect = overscrollEffect
            )
    ) {
        dismissContent()
    }
}

결과

위의 코드 스니펫은 'Drag me to dismiss!'라는 텍스트가 있는 흰색 배너를 생성합니다. 사용자는 왼쪽으로 스와이프하여 배너를 닫거나 오른쪽으로 드래그하여 오버스크롤 늘이기 효과를 볼 수 있습니다.

동영상 2. 스와이프하여 닫기 구성요소의 맞춤 구현입니다.

예: 맞춤 동작 기반 탐색 창 만들기

이 예에서는 맞춤 동작 기반 탐색 창을 빌드하는 방법을 보여줍니다. 사용자가 가로로 드래그하면 포그라운드 화면 (ScreenContents)이 오른쪽으로 슬라이드되고 1.0f에서 0.8f로 축소되며 모서리가 둥글게 처리되어 기본 탐색 창이 표시됩니다.

  1. 앵커 상태 및 위치를 정의합니다. 이 예시에서 앵커는 창의 열림 및 닫힘 상태입니다. Open 및 Closed 상태의 enum을 정의하고 DraggableAnchors를 사용하여 픽셀 오프셋에 매핑합니다.

    enum class DrawerState {
        Open,
        Closed
    }
    
        val anchors = DraggableAnchors {
            DrawerState.Open at drawerWidth
            DrawerState.Closed at 0f
        }
        val state = remember {
            AnchoredDraggableState(
                initialValue = DrawerState.Closed,
                anchors = anchors,
            )
        }

  2. 항목을 클릭하면 anchoredDraggableState에 애니메이션 적용 HomeScreenDrawer를 Box 하단에 배치하고 ScreenContents를 상단에 배치합니다. 창은 고정된 상태로 유지되고 상단 레이어가 그 위에서 이동하고 크기가 조정됩니다.

    사용자가 창 항목을 선택하면 활성 화면 상태를 업데이트하고 state.animateTo(DrawerState.Closed)를 사용하여 창을 자동으로 닫습니다.

    @Composable
    private fun AnchoredDraggableDrawerOnClickSample(
        modifier: Modifier = Modifier,
        state: AnchoredDraggableState<DrawerState>,
        coroutineScope: CoroutineScope
    ) {
        var screenState by remember { mutableStateOf(ScreenState.Home) }
    
        Box(modifier = modifier.fillMaxSize()) {
            HomeScreenDrawer(
                selectedScreen = screenState,
                onScreenSelected = { screen ->
                    screenState = screen
                    // Dismiss drawer when a destination is selected
                    coroutineScope.launch { state.animateTo(DrawerState.Closed) }
                }
            )
    
            // ScreenContents(...)
        }
    }

  3. state.requireOffset()를 사용하여 여러 시각적 속성 변경 콘텐츠만 오프셋하는 대신 Modifier.graphicsLayer를 사용하여 여러 속성을 변경하여 드래그 진행 상황에 따라 변환, 배율, 모서리 반경에 동시에 애니메이션을 적용할 수 있습니다.

    • state.requireOffset()를 사용하여 현재 드래그 오프셋을 가져옵니다.
    • lerp() (선형 보간법)을 사용하여 오프셋 및 창 너비에 상대적인 비례 배율 (1f~0.8f)과 모서리 반경 (0.dp~32.dp)을 계산합니다.
    • Modifier.graphicsLayer는 그리기 단계에서 변경사항을 실행하여 드래그 중에 재구성을 트리거하지 않고도 고성능 애니메이션을 보장합니다.

    @Composable
    private fun AnchoredDraggableDrawerGraphicsLayerSample(
        state: AnchoredDraggableState<DrawerState>,
        drawerWidth: Float,
        selectedScreen: ScreenState,
        onDrawerClicked: () -> Unit
    ) {
        ScreenContents(
            selectedScreen = selectedScreen,
            onDrawerClicked = onDrawerClicked,
            modifier = Modifier
                .graphicsLayer {
                    this.translationX = state.requireOffset()
                    val scale = lerp(1f, 0.8f, state.requireOffset() / drawerWidth)
                    this.scaleX = scale
                    this.scaleY = scale
                    val cornerRadius = lerp(0f, 32f, state.requireOffset() / drawerWidth)
                    this.shape = RoundedCornerShape(cornerRadius.dp)
                    this.clip = true
                }
                .anchoredDraggable(state, Orientation.Horizontal)
        )
    }

  4. animateTo로 프로그래매틱 애니메이션 트리거 코루틴 범위 내에서 state.animateTo()를 실행하여 프로그래매틱 방식으로 창을 제어합니다 (예: 메뉴 아이콘을 탭하거나 창 항목을 선택할 때).

    fun toggleDrawerState(
        state: AnchoredDraggableState<DrawerState>,
        coroutineScope: CoroutineScope
    ) {
        coroutineScope.launch {
            if (state.currentValue == DrawerState.Open) {
                state.animateTo(DrawerState.Closed)
            } else {
                state.animateTo(DrawerState.Open)
            }
        }
    }

결과

동영상 3. 포그라운드 화면 (수면)이 오른쪽으로 드래그되어 기본 탐색 창이 표시됩니다.

Modifier.draggable2D를 사용하여 두 방향으로 드래그

draggable2D 수정자를 사용하여 개별 UI 요소를 X축과 Y축 모두에서 동시에 이동합니다.

컴포저블 엘리먼트 드래그

이 예에서는 draggable2D의 가장 일반적인 사용 사례를 보여줍니다. 즉, 사용자가 UI 요소를 선택하여 상위 컨테이너 내의 아무 곳에나 재배치할 수 있습니다.

@Composable
private fun DraggableComposableElement() {
    // 1. Track the position of the floating window
    var offset by remember { mutableStateOf(Offset.Zero) }

    Box(modifier = Modifier.fillMaxSize().background(Color(0xFFF5F5F5))) {
        Box(
            modifier = Modifier
                // 2. Apply the offset to the box's position
                .offset { IntOffset(offset.x.roundToInt(), offset.y.roundToInt()) }
                // ...
                // 3. Attach the 2D drag logic
                .draggable2D(
                    state = rememberDraggable2DState { delta ->
                        // 4. Update the position based on the movement delta
                        offset += delta
                    }
                ),
            contentAlignment = Alignment.Center
        ) {
            Text("Video Preview", color = Color.White, fontSize = 12.sp)
        }
    }
}

동영상 4. 회색 배경에서 작은 보라색 상자의 위치가 변경되는 모습. 사용자가 손가락을 떼는 순간 요소의 움직임이 멈추는 직접 2D 드래그를 보여줍니다.

위의 코드 스니펫에는 다음이 포함됩니다.

  • offset 상태를 사용하여 상자의 위치를 추적합니다.
  • offset 수정자를 사용하여 드래그 델타에 따라 구성요소의 위치를 이동합니다.
  • 플링 지원이 없으므로 사용자가 손가락을 떼는 순간 상자가 움직임을 멈춥니다.

상위 요소의 드래그 영역을 기반으로 하위 컴포저블 드래그

이 예시에서는 draggable2D를 사용하여 선택기 노브가 특정 표면 내로 제한되는 2D 입력 영역을 만드는 방법을 보여줍니다. 구성요소 자체를 이동하는 드래그 가능한 요소 예와 달리 이 구현에서는 2D 델타를 사용하여 색상 선택 도구에서 하위 컴포저블 '선택기'를 이동합니다.

@Composable
private fun ExampleColorSelector(
    // ...
)  {
    // 1. Maintain the 2D position of the selector in state.
    var selectorOffset by remember { mutableStateOf(Offset.Zero) }

    // 2. Track the size of the background container.
    var containerSize by remember { mutableStateOf(IntSize.Zero) }

    Box(
        modifier = Modifier
            .size(300.dp, 200.dp)
            // Capture the actual pixel dimensions of the container when it's laid out.
            .onSizeChanged { containerSize = it }
            .clip(RoundedCornerShape(12.dp))
            .background(
                brush = remember(hue) {
                    // Create a simple gradient representing Saturation and Value for the given Hue.
                    Brush.linearGradient(listOf(Color.White, Color.hsv(hue, 1f, 1f)))
                }
            )
    ) {
        Box(
            modifier = Modifier
                .size(24.dp)
                .graphicsLayer {
                    // Center the selector on the finger by subtracting half its size.
                    translationX = selectorOffset.x - (24.dp.toPx() / 2)
                    translationY = selectorOffset.y - (24.dp.toPx() / 2)
                }
                // ...
                // 3. Configure 2D touch dragging.
                .draggable2D(
                    state = rememberDraggable2DState { delta ->
                        // 4. Calculate the new position and clamp it to the container bounds
                        val newX = (selectorOffset.x + delta.x)
                            .coerceIn(0f, containerSize.width.toFloat())
                        val newY = (selectorOffset.y + delta.y)
                            .coerceIn(0f, containerSize.height.toFloat())

                        selectorOffset = Offset(newX, newY)
                    }
                )
        )
    }
}

동영상 5. 어떤 방향으로든 드래그할 수 있는 흰색 원형 선택기 노브가 있는 색상 그라데이션으로, 2D 델타가 컨테이너 경계에 고정되어 선택된 색상 값을 업데이트하는 방법을 보여줍니다.

위 스니펫에는 다음이 포함됩니다.

  • onSizeChanged 수정자를 사용하여 그라데이션 컨테이너의 실제 크기를 캡처합니다. 선택기는 가장자리가 어디에 있는지 정확히 알고 있습니다.
  • graphicsLayer 내에서 translationX 및 translationY를 조정하여 드래그하는 동안 선택기가 중앙에 유지되도록 합니다.