Jetpack Compose에서 scrollable2D 및 draggable2D는
2차원에서 포인터 입력을 처리하도록 설계된 하위 수준 수정자입니다. 표준 1D 수정자 scrollable 및 draggable 은 단일 방향으로 제한되지만 2D 변형은 X축과 Y축 모두에서 동시에 움직임을 추적합니다.
예를 들어 기존 scrollable 수정자는 단일 방향
스크롤 및 플링에 사용되는 반면 scrollable2d는 2D에서 스크롤 및 플링에 사용됩니다. 이를 통해 스프레드시트 또는 이미지 뷰어와 같이 모든 방향으로 이동하는 더 복잡한 레이아웃을 만들 수 있습니다. scrollable2d 수정자는 2D 시나리오에서 중첩 스크롤도 지원합니다.
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()}", // ... ) } } } }
위 스니펫에서는 다음을 실행합니다.
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 ) } }
Modifier.scrollable2D로 만든 양방향 패닝 이미지 표시 영역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) ) } }
위 스니펫에서 다음이 실행됩니다.
- 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) } } }
위 코드 스니펫에는 다음이 포함됩니다.
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) } ) ) } }
위 스니펫에는 다음이 포함됩니다.
onSizeChanged수정자를 사용하여 그라데이션 컨테이너의 실제 크기를 캡처합니다. 선택기는 가장자리가 어디에 있는지 정확히 알고 있습니다.graphicsLayer내에서translationX및translationY를 조정하여 드래그하는 동안 선택기가 중앙에 유지되도록 합니다.