2차원 스크롤: scrollable2D, draggable2D

Jetpack Compose에서 scrollable2D 및 draggable2D는 2차원에서 포인터 입력을 처리하도록 설계된 하위 수준 수정자입니다. 표준 1D 수정자 scrollable 및 draggable는 단일 방향으로 제한되지만 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 스크롤 가능 데이터를 사용하고 상위 컨테이너보다 큰 콘텐츠에 translationX 및 translationY를 적용하는 방법을 보여줍니다.

@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.x 및 translationY = 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 구성요소는 X축 움직임을 사용하여 내부적으로 화면 이동하는 동시에 하위 요소의 자체 수직 경계에 도달하면 Y축 움직임을 상위 목록에 전달할 수 있습니다.
  • 시스템은 사용자를 2D 표면 내에 가두는 대신 소비된 델타를 계산하고 나머지를 계층 구조 위로 전달합니다. 이렇게 하면 사용자가 손가락을 떼지 않고도 페이지의 나머지 부분을 계속 스크롤할 수 있습니다.

Modifier.draggable2D 구현

Modifier.draggable2D 사용 방법에 관한 자세한 내용은 Modifier.draggable2D를 사용하여 2차원으로 드래그를 참고하세요.