Two-dimensional scrolling: scrollable2D, draggable2D

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.

Figure 1. A bi-directional panning on a map.

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
privatefunScrollable2DSample(){
// 1. Manually track the total distance the user has moved in both X and Y directions
varoffsetbyremember{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()}",
// ...
)
}
}
}
}

Figure 2. A purple box that tracks and displays the current X and Y coordinate offsets as a user drags the pointer across its surface.

The preceding snippet does the following:

  • Uses offset as 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 code offset.value += delta updates the manual state with the new position.
  • The Text components display the current X and Y values of that offset state, 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
privatefunPanning2DImage(){
// Manually track the total distance the user has moved in both X and Y directions
valoffset=remember{mutableStateOf(Offset.Zero)}
// Define how gestures are captured. The lambda is called for every finger movement
valscrollState=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
)
}
}

Figure 3. A bi-directional panning image viewport, created with Modifier.scrollable2D.
Figure 4. A bi-directional panning text viewport, created with 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 the 600x400 box is hidden from view.
  • Unlike high-level components like LazyColumn, scrollable2D does not move the content for you automatically. Instead, you must apply the tracked offset to your content, either using graphicsLayer transformations or layout offsets.
  • Inside the graphicsLayer block, translationX = offset.value.x and translationY = offset.value.y shift 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 rememberScrollable2DState should 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
privatefunNestedScrollable2DSample(){
varoffsetbyremember{mutableStateOf(Offset.Zero)}
valmaxScrollDp=250.dp
valmaxScrollPx=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->
valoldOffset=offset
// Calculate new potential offset and clamp it to our boundaries
valnewX=(oldOffset.x+delta.x).coerceIn(-maxScrollPx,maxScrollPx)
valnewY=(oldOffset.y+delta.y).coerceIn(-maxScrollPx,maxScrollPx)
valnewOffset=Offset(newX,newY)
// Calculate exactly how much was consumed by the child
valconsumed=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){
valdensity=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)
)
}
}

Figure 5. A purple box within a vertical scrolling list that allows internal 2D movement, but passes vertical scroll control to the parent list once the box's internal Y-offset reaches its 300-pixel limit.

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
privatefunDraggableComposableElement(){
// 1. Track the position of the floating window
varoffsetbyremember{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)
}
}
}

Figure 6. A small purple box being repositioned on a gray background, demonstrating direct 2D dragging where the element stops moving the instant the user's finger is lifted.

The preceding code snippet includes the following:

  • Tracks the box's position using an offset state.
  • Uses offset modifier 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
privatefunExampleColorSelector(
// ...
){
// 1. Maintain the 2D position of the selector in state.
varselectorOffsetbyremember{mutableStateOf(Offset.Zero)}
// 2. Track the size of the background container.
varcontainerSizebyremember{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
valnewX=(selectorOffset.x+delta.x)
.coerceIn(0f,containerSize.width.toFloat())
valnewY=(selectorOffset.y+delta.y)
.coerceIn(0f,containerSize.height.toFloat())
selectorOffset=Offset(newX,newY)
}
)
)
}
}

Figure 7. A color gradient with a white circular selector knob that can be dragged in any direction, demonstrating how 2D deltas are clamped to the container's boundaries to update selected color values.

The preceding snippet includes the following:

  • It uses the onSizeChanged modifier to capture the actual dimensions of the gradient container. The selector knows exactly where the edges are.
  • Inside the graphicsLayer, it adjusts the translationX and translationY to make sure the selector stays centered while dragging.

Content and code samples on this page are subject to the licenses described in the Content License. Java and OpenJDK are trademarks or registered trademarks of Oracle and/or its affiliates.

Last updated 2026年08月14日 UTC.