In Jetpack Compose, scrollable2D and draggable2D are
low-level modifiers designed to handle pointer input in two dimensions. While
the standard 1D modifiers scrollable and draggable are
restricted to a single orientation, the 2D variants track movement across both
the X and Y axes simultaneously.
For example, the existing scrollable modifier is used for single-orientation
scrolling and flinging, while scrollable2d is used for scrolling and flinging
in 2D. This allows you to create more complex layouts that move in all
directions, such as spreadsheets or image viewers. The scrollable2d modifier
also supports nested scrolling in 2D scenarios.
Choose scrollable2D or draggable2D
Choosing the right API depends on the UI elements you want to move and the preferred physical behavior for these elements.
Modifier.scrollable2D: Use this modifier on a container to move content inside it. For example, use it with maps, spreadsheets, or photo viewers, where the container's content needs to scroll in both horizontal and vertical directions. It includes built-in fling support so the content keeps moving after a swipe, and it coordinates with other scrolling components on the page.
Modifier.draggable2D: Use this modifier to move a component itself. It's a lightweight modifier, so the movement stops exactly when the user's finger stops. It does not include fling support.
If you want to make a component draggable, but don't need fling or nested scroll
support, use draggable2D.
Implement 2D modifiers
The following sections provide examples to show how to use the 2D modifiers.
Implement Modifier.scrollable2D
Use this modifier for containers where the user needs to move content in all directions.
Capture 2D movement data
This example shows how to capture raw 2D movement data and display the X,Y offset:
@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()}", // ... ) } } } }
The preceding snippet does the following:
- Uses
offsetas a state that holds the total distance the user has scrolled. - Inside
rememberScrollable2DState, a lambda function is defined to handle every delta, generated by the user's finger. The codeoffset.value += deltaupdates the manual state with the new position. - The
Textcomponents display the current X and Y values of thatoffsetstate, which update in real-time as the user drags.
Pan a large viewport
This example shows how to use captured 2D scrollable data and apply a
translationX and translationY to content that is larger than its parent
container:
@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.The preceding snippet includes the following:
- The container is set to a fixed size (
600x400dp), while the content is given a much larger size (1200x800dp) to avoid it resizing to its parent size. - The
clipToBounds()modifier on the container ensures that any part of the large content that sits outside the600x400box is hidden from view. - Unlike high-level components like
LazyColumn,scrollable2Ddoes not move the content for you automatically. Instead, you must apply the trackedoffsetto your content, either usinggraphicsLayertransformations or layout offsets. - Inside the
graphicsLayerblock,translationX = offset.value.xandtranslationY = offset.value.yshift the drawing position of the image or text based on your finger's movement, creating the visual effect of scrolling.
Implement nested scrolling with scrollable2D
This example demonstrates how a bi-directional component can be integrated into a standard one-dimensional parent, like a vertical news feed.
Keep the following points in mind when implementing nested scrolling:
- The lambda for
rememberScrollable2DStateshould return only the consumed delta, to let the parent list take over naturally when the child reaches its limit. - When a user performs a diagonal fling, the 2D velocity is shared. If the child hits a boundary during the animation, the remaining momentum is propagated to the parent to continue the scroll naturally.
@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) ) } }
In the preceding snippet:
- The 2D component can consume X axis movement to pan internally while simultaneously dispatching Y axis movement to the parent list once the child's own vertical boundaries are reached.
- Instead of trapping the user within the 2D surface, the system calculates the consumed delta and passes the remainder up the hierarchy. This ensures the user can continue scrolling through the rest of the page without lifting their finger.
Implement Modifier.draggable2D
Use the draggable2D modifier for moving individual UI elements.
Drag a composable element
This example shows the most common use case for draggable2D — allowing a user
to pick up a UI element and reposition it anywhere within a parent container.
@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) } } }
The preceding code snippet includes the following:
- Tracks the box's position using an
offsetstate. - Uses
offsetmodifier to shift the component's position based on the drag deltas. - Since there is no fling support, the box stops moving the instant the user lifts their finger.
Drag a child composable based on parent's drag area
This example demonstrates how to use draggable2D to create a 2D input area
where a selector knob is constrained within a specific surface. Unlike the
draggable element example, which moves the component itself, this implementation
uses the 2D deltas to move a child composable "selector" across a color picker:
@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) } ) ) } }
The preceding snippet includes the following:
- It uses the
onSizeChangedmodifier to capture the actual dimensions of the gradient container. The selector knows exactly where the edges are. - Inside the
graphicsLayer, it adjusts thetranslationXandtranslationYto make sure the selector stays centered while dragging.