드래그 컴포저블

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

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

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

올바른 드래그 API 선택

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

  • 다음과 같은 경우 표준 Material 구성요소 (예: SwipeToDismissBox 또는 탐색 창)를 선택합니다.: 기본 Material 3 스와이프 또는 드래그 동작 (예: 이메일 삭제, 작업 보관처리 또는 사이드 메뉴 / 하단 시트 열기)이 필요합니다. 맞춤 상태 또는 레이아웃 관리가 필요하지 않은 기본 제공 동작을 제공합니다.
  • 다음과 같은 경우 Modifier.draggable 을 선택합니다. 원시 드래그 델타만 필요합니다 (예: 맞춤 슬라이더 드래그). 자세한 내용은 드래그 가능으로 단일 축을 따라 드래그를 참고하세요.
    • 다음과 같은 경우 Modifier.draggable2D 를 선택합니다.: X축과 Y축 모두에서 동시에 계속 이동하는 구성요소가 필요합니다 (예: 지도에서 이동 또는 사용자가 화면의 아무 곳으로나 드래그할 수 있는 플로팅 작업 버튼 만들기). 자세한 내용은 Modifier.draggable2D로 2차원 드래그를 참고하세요.
  • 다음과 같은 경우 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, and 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()
    }
}

결과

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

동영상 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를 애니메이션 처리합니다. Box 하단에 HomeScreenDrawer를 배치하고 상단에 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로 2차원 드래그

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

컴포저블 요소 드래그

이 예에서는 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를 조정하여 드래그하는 동안 선택기가 중앙에 유지되도록 합니다.