2차원 스크롤: scrollable2D, draggable2D

Jetpack Compose에서 scrollable2Ddraggable2D는 2차원에서 포인터 입력을 처리하도록 설계된 하위 수준 수정자입니다. 표준 1D 수정자 scrollabledraggable 은 단일 방향으로 제한되지만 2D 변형은 X축과 Y축 모두에서 동시에 움직임을 추적합니다.

예를 들어 기존 scrollable 수정자는 단일 방향 스크롤 및 플링에 사용되는 반면 scrollable2d는 2D에서 스크롤 및 플링에 사용됩니다. 이를 통해 스프레드시트 또는 이미지 뷰어와 같이 모든 방향으로 이동하는 더 복잡한 레이아웃을 만들 수 있습니다. scrollable2d 수정자는 2D 시나리오에서 중첩 스크롤도 지원합니다.

그림 1. 지도에서 양방향 패닝

scrollable2D 또는 draggable2D 선택

올바른 API를 선택하는 것은 이동하려는 UI 요소와 이러한 요소에 선호되는 실제 동작에 따라 다릅니다.

Modifier.scrollable2D: 컨테이너에서 이 수정자를 사용하여 컨테이너 내부의 콘텐츠를 이동합니다. 예를 들어 컨테이너의 콘텐츠가 가로 및 세로 방향으로 모두 스크롤해야 하는 지도, 스프레드시트 또는 사진 뷰어와 함께 사용합니다. 스와이프 후에도 콘텐츠가 계속 이동하도록 플링 지원이 내장되어 있으며 페이지의 다른 스크롤 구성요소와 조정됩니다.

Modifier.draggable2D: 이 수정자를 사용하여 구성요소 자체를 이동합니다. 경량 수정자이므로 사용자의 손가락이 멈추면 움직임이 정확히 멈춥니다. 플링 지원은 포함되지 않습니다.

구성요소를 드래그 가능하게 만들고 싶지만 플링 또는 중첩 스크롤 지원이 필요하지 않은 경우 draggable2D를 사용합니다.

2D 수정자 구현

다음 섹션에서는 2D 수정자를 사용하는 방법을 보여주는 예를 제공합니다.

Modifier.scrollable2D 구현

사용자가 모든 방향으로 콘텐츠를 이동해야 하는 컨테이너에 이 수정자를 사용합니다.

2D 이동 데이터 캡처

이 예에서는 원시 2D 이동 데이터를 캡처하고 X,Y 오프셋을 표시하는 방법을 보여줍니다.

@Composable
private fun Scrollable2DSample() {
    // 1. Manually track the total distance the user has moved in both X and Y directions
    var offset by remember { mutableStateOf(Offset.Zero) }

    Box(
        modifier = Modifier
            .fillMaxSize()
            // ...
        contentAlignment = Alignment.Center
    ) {
        Box(
            modifier = Modifier
                .size(200.dp)
                // 2. Attach the 2D scroll logic to capture XY movement deltas
                .scrollable2D(
                    state = rememberScrollable2DState { delta ->
                        // 3. Update the cumulative offset state with the new movement delta
                        offset += delta

                        // Return the delta to indicate the entire movement was handled by this box
                        delta
                    }
                )
                // ...
            contentAlignment = Alignment.Center
        ) {
            Column(horizontalAlignment = Alignment.CenterHorizontally) {
                // 4. Display the current X and Y values from the offset state in real-time
                Text(
                    text = "X: ${offset.x.roundToInt()}",
                    // ...
                )
                Spacer(modifier = Modifier.height(8.dp))
                Text(
                    text = "Y: ${offset.y.roundToInt()}",
                    // ...
                )
            }
        }
    }
}

그림 2. 사용자가 포인터를 표면 위로 드래그할 때 현재 X 및 Y 좌표 오프셋을 추적하고 표시하는 보라색 상자

위 스니펫에서는 다음을 실행합니다.

  • offset을 사용자가 스크롤한 총 거리를 보유하는 상태로 사용합니다.
  • rememberScrollable2DState 내에서 람다 함수는 사용자의 손가락으로 생성된 모든 델타를 처리하도록 정의됩니다. 코드 offset.value += delta는 새 위치로 수동 상태를 업데이트합니다.
  • Text 구성요소는 사용자가 드래그할 때 실시간으로 업데이트되는 offset 상태의 현재 X 및 Y 값을 표시합니다.

큰 표시 영역 패닝

이 예에서는 캡처된 2D 스크롤 가능 데이터를 사용하고 상위 컨테이너보다 큰 콘텐츠에 translationXtranslationY를 적용하는 방법을 보여줍니다.

@Composable
private fun Panning2DImage() {

    // Manually track the total distance the user has moved in both X and Y directions
    val offset = remember { mutableStateOf(Offset.Zero) }

    // Define how gestures are captured. The lambda is called for every finger movement
    val scrollState = rememberScrollable2DState { delta ->
        offset.value += delta
        delta
    }

    // The Viewport (Container): A fixed-size box that acts as a window into the larger content
    Box(
        modifier = Modifier
            .size(600.dp, 400.dp) // The visible area dimensions
            // ...
            // Hide any parts of the large content that sit outside this container's boundaries
            .clipToBounds()
            // Apply the 2D scroll modifier to intercept touch and fling gestures in all directions
            .scrollable2D(state = scrollState),
        contentAlignment = Alignment.Center,
    ) {
        // The Content: An image given a much larger size than the container viewport
        Image(
            painter = painterResource(R.drawable.cheese_5),
            contentDescription = null,
            modifier = Modifier
                .requiredSize(1200.dp, 800.dp)
                // Manual Scroll Effect: Since scrollable2D doesn't move content automatically,
                // we use graphicsLayer to shift the drawing position based on the tracked offset.
                .graphicsLayer {
                    translationX = offset.value.x
                    translationY = offset.value.y
                },
            contentScale = ContentScale.FillBounds
        )
    }
}

그림 3. Modifier.scrollable2D로 만든 양방향 패닝 이미지 표시 영역
그림 4. Modifier.scrollable2D로 만든 양방향 패닝 텍스트 표시 영역

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

  • 컨테이너는 고정 크기 (600x400dp)로 설정되는 반면 콘텐츠는 상위 크기로 크기가 조정되지 않도록 훨씬 더 큰 크기 (1200x800dp)로 지정됩니다.
  • 컨테이너의 clipToBounds() 수정자는 600x400 상자 외부에 있는 큰 콘텐츠의 모든 부분이 뷰에서 숨겨지도록 합니다.
  • LazyColumn과 같은 상위 수준 구성요소와 달리 scrollable2D는 콘텐츠를 자동으로 이동하지 않습니다. 대신 graphicsLayer 변환 또는 레이아웃 오프셋을 사용하여 추적된 offset을 콘텐츠에 적용해야 합니다.
  • graphicsLayer 블록 내에서 translationX = offset.value.xtranslationY = offset.value.y는 손가락의 움직임에 따라 이미지 또는 텍스트의 그리기 위치를 이동하여 스크롤의 시각적 효과를 만듭니다.

scrollable2D로 중첩 스크롤 구현

이 예에서는 양방향 구성요소를 세로 뉴스 피드와 같은 표준 1차원 상위에 통합하는 방법을 보여줍니다.

중첩 스크롤을 구현할 때는 다음 사항에 유의하세요.

  • rememberScrollable2DState의 람다는 하위 요소가 한도에 도달할 때 상위 목록이 자연스럽게 인계 되도록 사용된 델타 만 반환해야 합니다.
  • 사용자가 대각선 플링을 실행하면 2D 속도가 공유됩니다. 애니메이션 중에 하위 요소가 경계에 도달하면 나머지 모멘텀이 전파 되어 상위 요소가 자연스럽게 스크롤을 계속합니다.

@Composable
private fun NestedScrollable2DSample() {
    var offset by remember { mutableStateOf(Offset.Zero) }
    val maxScrollDp = 250.dp
    val maxScrollPx = with(LocalDensity.current) { maxScrollDp.toPx() }

    Column(
        modifier = Modifier
            .fillMaxSize()
            .verticalScroll(rememberScrollState())
            .background(Color(0xFFF5F5F5)),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text(
            "Scroll down to find the 2D Box",
            modifier = Modifier.padding(top = 100.dp, bottom = 500.dp),
            style = TextStyle(fontSize = 18.sp, color = Color.Gray)
        )

        // The Child: A 2D scrollable box with nested scroll coordination
        Box(
            modifier = Modifier
                .size(250.dp)
                .scrollable2D(
                    state = rememberScrollable2DState { delta ->
                        val oldOffset = offset

                        // Calculate new potential offset and clamp it to our boundaries
                        val newX = (oldOffset.x + delta.x).coerceIn(-maxScrollPx, maxScrollPx)
                        val newY = (oldOffset.y + delta.y).coerceIn(-maxScrollPx, maxScrollPx)

                        val newOffset = Offset(newX, newY)

                        // Calculate exactly how much was consumed by the child
                        val consumed = newOffset - oldOffset

                        offset = newOffset

                        // IMPORTANT: Return ONLY the consumed delta.
                        // The remaining (unconsumed) delta propagates to the parent Column.
                        consumed
                    }
                )
                // ...
            contentAlignment = Alignment.Center
        ) {
            Column(horizontalAlignment = Alignment.CenterHorizontally) {
                val density = LocalDensity.current
                Text("2D Panning Zone", color = Color.White.copy(alpha = 0.7f), fontSize = 12.sp)
                Spacer(Modifier.height(8.dp))
                Text("X: ${with(density) { offset.x.toDp().value.roundToInt() }}dp", color = Color.White, fontWeight = FontWeight.Bold)
                Text("Y: ${with(density) { offset.y.toDp().value.roundToInt() }}dp", color = Color.White, fontWeight = FontWeight.Bold)
            }
        }

        Text(
            "Once the Purple Box hits Y: 250 or -250,\nthis parent list will take over the vertical scroll.",
            textAlign = TextAlign.Center,
            modifier = Modifier.padding(top = 40.dp, bottom = 800.dp),
            style = TextStyle(fontSize = 14.sp, color = Color.Gray)
        )
    }
}

그림 5. 내부 2D 이동을 허용하지만 상자의 내부 Y 오프셋이 300픽셀 한도에 도달하면 세로 스크롤 컨트롤을 상위 목록에 전달하는 세로 스크롤 목록 내의 보라색 상자

위 스니펫에서 다음이 실행됩니다.

  • 2D 구성요소는 하위 요소의 자체 세로 경계에 도달하면 Y축 이동을 상위 목록에 동시에 디스패치하면서 X축 이동을 사용하여 내부적으로 패닝할 수 있습니다.
  • 시스템은 사용자를 2D 표면 내에 가두는 대신 사용된 델타를 계산하고 나머지 부분을 계층 구조로 전달합니다. 이렇게 하면 사용자가 손가락을 떼지 않고도 페이지의 나머지 부분을 계속 스크롤할 수 있습니다.

Modifier.draggable2D 구현

개별 UI 요소를 이동하려면 draggable2D 수정자를 사용합니다.

컴포저블 요소 드래그

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

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

그림 6. 사용자의 손가락이 떼지는 즉시 요소가 이동을 멈추는 직접 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)
                    }
                )
        )
    }
}

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

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

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