Gestures in Jetpack compose — All you need to know — Part 1

An In-Depth Guide to Jetpack Compose Gesture Handling

Radhika S
Canopas
13 min readOct 4, 2023

--

Designed By Canopas

Background

Gestures play a pivotal role in user interaction, offering a means to navigate, select, and manipulate elements within an app.

From simple taps and swipes to complex pinch-to-zoom and multi-touch gestures, Jetpack Compose provides a robust framework to seamlessly integrate these interactions into your application’s UI.

In this comprehensive guide, we embark on a deep dive into Jetpack Compose’s gesture-handling capabilities. We’ll explore everything you need to know, from the basics of detecting taps to advanced techniques like handling fling effects and tracking interactions manually.

Table of Contents

1. List of Gesture Modifiers and Gesture Detectors. [In this blog]
2. How to detect tap? [In this blog]
3. How to detect movements like drag, swipe etc.? [In this blog]
4. How to detect scroll gestures? [In this blog]

-> Below things we will cover in the second part.

5. How to handle multiple gestures together? [Part 2]
6. How to handle a fling effect with gesture? [Part 2]
7. How to track interaction manually? [Part 2]
8. How to disable interaction? [Part 2]

Sponsored

We are what we repeatedly do. Excellence, then, is not an act, but a habit. Try out Justly and start building your habits today!

Gesture Modifiers and Gesture Detectors

In compose, any kind of user input that interacts with the screen is called a Pointer. From tapping on the screen to moving the finger and releasing the tap by figure up is the gesture. Jetpack Compose provides a wide API range to handle gestures. Let’s divide it into modifiers and Detectors.

1. The low-level modifier

Modifier.pointerInput() is for processing the raw pointer inputs or we can say events.

2. The gesture detectors

Compose provides inbuilt recognizers to detect specific gestures in the pointerInput modifier. These detectors detect the specific movement in PointerInputScope

Modifier.pointerInput(Unit) { 
detectTapGestures(onTap = {}, onDoubleTap = {},
onLongPress = {}, onPress = {})

detectDragGestures(onDrag = { change, dragAmount -> },
onDragStart = {}, onDragEnd = {},
onDragCancel = {})

detectHorizontalDragGestures(onHorizontalDrag = {change, dragAmount -> },
onDragStart = {}, onDragEnd = {}, onDragCancel = {})

detectVerticalDragGestures(onVerticalDrag = {change, dragAmount -> },
onDragStart = {},onDragEnd = {}, onDragCancel = {})

detectDragGesturesAfterLongPress(onDrag = { change, dragAmount -> },
onDragStart = {}, onDragEnd = {},onDragCancel = {})

detectTransformGestures(panZoomLock = false,
onGesture = {
centroid: Offset, pan: Offset, zoom: Float, rotation: Float ->
})
}

detectTapGestures(...) : detect different touch gestures like taps, double-taps, and long presses.

Keep in mind:

  • If you provide onDoubleTap, the system waits for a short time before considering a tap as a double-tap. If it’s a double-tap, onDoubleTap is called; otherwise, onTap is called.
  • If the user’s touch moves away from the element or another gesture takes over, the gestures are considered cancelled. This means that onDoubleTap, onLongPress, and onTap won’t be called if the gesture is cancelled.
  • If something else consumes the initial touch event (the first down event), then the entire gesture is skipped, including the onPress function.

detectDragGestures(...) : detect dragging gestures, like when a user swipes or drags something on the screen.

  1. Drag Start: When a user touches the screen and starts to move their finger (dragging), the onDragStart function is called. It provides the initial touch position.
  2. Drag Update: As the user continues to drag, the onDrag function is called repeatedly. It provides information about how much the user has moved their finger (the dragAmount) and where their finger currently is (the change object).
  3. Drag End: When the user lifts their finger, indicating the end of the drag, the onDragEnd function is called.
  4. Drag Cancel: If something else happens, like another gesture taking over, the onDragCancel function is called, indicating that the drag gesture was cancelled.

detectHorizontalDragGestures(...) : it same detectDragGesturesbut ensures that the drag is only detected if the user moves their finger horizontally beyond a certain threshold (touch slop).

detectVerticalDragGestures(...) : ensures that the drag is only detected if the user moves their finger vertically.

detectDragGesturesAfterLongPress(...) : This function ensures that the drag is detected only after a long press gesture. It consumes all position changes after the long press, meaning it tracks the finger’s movement until the user releases their touch.

detectTransformGestures(...) : detect multi-touch gestures like rotation, panning (moving), and zooming (scaling) on an element.

3. The high-level modifiers

Compose has some Modifier that is built on top of the low-level pointer input modifier with some extra functionalities. Here’s the list of all available Modifiers that we can directly apply to the composable without PointerInput

Modifier.clickable(onClick: () -> Unit)

Modifier.combinedClickable(
enabled: Boolean = true,
onLongClick: (() -> Unit)? = null,
onDoubleClick: (() -> Unit)? = null,
onClick: () -> Unit
)

Modifier.draggable(state: DraggableState, orientation: Orientation, ...)

Modifier.anchoredDraggable(state: AnchoredDraggableState<T>,
orientation: Orientation, ...
)

Modifier.scrollable( state: ScrollableState, orientation: Orientation, ...)

Modifier.horizontalScroll(state: ScrollState, ...)

Modifier.verticalScroll( state: ScrollState, ...)

Modifier.transformable(state: TransformableState, ...)

You might have questioned why would we use gesture detectors or gesture Modifiers.

When we’re using detectors we’re purely detecting the events, whereas the modifiers, along with handling the events, contain more information than a raw pointer input implementation.

We should choose between them based on the complexity and specificity of touch interactions.

Now let’s see the use cases of these gesture detectors and modifiers.

How to handle/detect tap & press?

Many Jetpack Compose elements, such as Button, IconButton, come with built-in support for click-or-tap interactions. These elements are designed to be interactive by default, so you don't need to explicitly add a clickable modifier or gesture detection code. You can simply use these composable elements and provide the action to be executed when they are clicked or tapped.

Button(onClick = { /* Handle click action here*/ }) { Text("Click!") }

a. Modifier. clickable{}

The Modifier.clickable {} modifier is a simple and convenient way to handle tap gestures in Jetpack Compose. You apply it to a composable element like Box, and the lambda inside clickable is executed when the element is tapped.

With clickable Modifier, we can also add additional features such as interaction source, visual indication, focus, hovering etc. Let’s see an example where we change the corner radius based on the interaction source.

val interactionSource = remember { MutableInteractionSource() }
val isPressed = interactionSource.collectIsPressedAsState()
val cornerRadius by animateDpAsState(targetValue = if (isPressed.value) 10.dp else 50.dp)

Box(
modifier = Modifier
.background(color = pink, RoundedCornerShape(cornerRadius))
.size(100.dp)
.clip(RoundedCornerShape(cornerRadius))
.clickable(
interactionSource = interactionSource,
indication = rememberRipple()
) {
//Clicked
}
.padding(horizontal = 20.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "Click!",
color = Color.White
)
}

b. Modifier.combinedClickable{}

It’s used to handle double click or long click alongside the single click. Same as clickable Modifier, we can provide interaction source, an indication to combinedClickable

Box(
modifier = Modifier
.size(100.dp)
.background(Color.Blue)
.combinedClickable(
onClick = { /* Handle click action here */},
onDoubleClick = { /* Handle double click here */ },
onLongClick = { /* Handle long click here */ },
)
)

b. PointerInputScope.detectTapGestures()

Detect the raw input events.

Box(
modifier = Modifier
.size(100.dp)
.background(Color.Blue)
.pointerInput(Unit) {
detectTapGestures(
onTap = { /* Handle tap here */ },
onDoubleTap = {/* Handle double tap here */ },
onLongPress = { /* Handle long press here */ },
onPress = { /* Handle press here */ }
)
}
)

Now you might have a question, what if we use all the above together?

If we apply all to the same composable, then the first modifier in the chain will be replaced by a later one. If we specify clickable first and then combinedClickable we’ll get a tap event in combinedClickable instead of clickable Modifier.

For more details refer to this official documentation.

How to detect movements like drag, swipe etc.?

To detect drag we can use either Modifier.draggable, ordetectDragGesture also, experimental Modifier.anchoredDraggable API with anchored states like swipe-to-dismiss. Let’s see them one by one.

a. Modifier.draggable

Create a UI element that can be dragged in one direction (like left and right or up and down) and measure how far it’s dragged. Common for sliders or draggable components.

@Composable
fun DraggableContent() {
var offsetX by remember { mutableStateOf(0f) }

Box(modifier = Modifier.fillMaxSize()) {
Box(
modifier = Modifier.padding(top = 20.dp)
.graphicsLayer {
this.translationX = offsetX
}
.draggable(
state = rememberDraggableState {delta ->
offsetX += delta
}, orientation = Orientation.Horizontal
)
.size(50.dp)
.background(Color.Blue)

)
Text(text = "Offset $offsetX")
}
}

b. Modifier.anchoredDraggable

anchoredDraggable allows to drag content in one direction horizontally or vertically. This experimental API was recently introduced in 1.6.0-alpha01 it’s a replacement of Modifier.swipeable()

It has two important parts, AnchoredDraggableState and Modifier.anchoredDraggable . AnchoredDraggableState hold the state of dragging. TheanchoredDraggable modifier is built on top of Modifier.draggable().

When a drag is detected, it updates the offset of the AnchoredDraggableState with the drag's delta (change). This offset can be used to move the UI content accordingly. When the drag ends, the offset is smoothly animated to one of the predefined anchors, and the associated value is updated to match the new anchor.

Here’s a simple example,

@Composable
fun AnchoredDraggableDemo() {
val density = LocalDensity.current
val configuration = LocalConfiguration.current

val state = remember {
AnchoredDraggableState(
initialValue = DragAnchors.Start,
anchors = DraggableAnchors {
DragAnchors.Start at 0f
DragAnchors.End at 1000f
},
positionalThreshold = { distance: Float -> distance * 0.5f },
velocityThreshold = { with(density) { 100.dp.toPx() } },
animationSpec = tween(),
)
}

Box(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
Box(
modifier = Modifier
.padding(top = 40.dp)
.offset {
IntOffset(
x = state
.requireOffset()
.roundToInt(), y = 0
)
}
.anchoredDraggable(state = state, orientation = Orientation.Horizontal)
.size(50.dp)
.background(Color.Red)

)
}
}

c. detectDragGesture

For whole control over dragging gestures, use the drag gesture detector with the pointer input modifier.

@Composable
fun DraggableContent() {
var offsetX by remember { mutableStateOf(0f) }
var offsetY by remember { mutableStateOf(0f) }

Box(modifier = Modifier
.fillMaxSize()
.padding(16.dp)) {
Box(
modifier = Modifier
.padding(top = 40.dp)
.graphicsLayer {
this.translationX = offsetX
this.translationY = offsetY
}
.pointerInput(Unit) {
detectDragGestures { change, dragAmount ->
change.consume()
offsetX += dragAmount.x
offsetY += dragAmount.y
}
}
.size(50.dp)
.background(Color.Red)

)
Text(text = "Offset X $offsetX \nOffset Y: $offsetY")
}
}

d. detectHorizontalDragGestures & detectVerticalDragGestures

Similar to the above gesture detector, to detect drag in a specific direction, we have these two gestures.

@Composable
fun DraggableContent() {
var offsetX by remember { mutableStateOf(0f) }
var offsetY by remember { mutableStateOf(0f) }

Box(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
Box(
modifier = Modifier
.padding(top = 40.dp)
.offset {
IntOffset(x = offsetX.roundToInt(), y = offsetY.roundToInt())
}
.pointerInput(Unit) {
detectHorizontalDragGestures { change, dragAmount ->
change.consume()
offsetX += dragAmount
}
/*
detectVerticalDragGestures { change, dragAmount ->
change.consume()
offsetY += dragAmount
}
*/
}
.size(50.dp)
.background(Color.Red)

)
Text(text = "Offset X $offsetX \nOffset Y: $offsetY")
}
}

So what if we specify both in the same Pointer input scope?

Simply, the first gesture detector in the chain will invoke, and later one ignored. If you want to use both gestures for composable, chain two Modifier.pointerInput .

e. detectDragGesturesAfterLongPress

This gesture detection allows us to have fine control over drag, it invokes the onDrag call back only after a long press. This can be helpful in various scenarios where you want to provide users with a way to rearrange or interact with items in a list, reorder elements in a grid, or perform actions that require confirmation or initiation through a long press before allowing dragging.

@Preview
@Composable
fun DraggableContent() {
var offsetX by remember { mutableStateOf(0f) }
var offsetY by remember { mutableStateOf(0f) }

Box(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
Box(
modifier = Modifier
.padding(top = 40.dp)
.offset {
IntOffset(x = offsetX.roundToInt(), y = offsetY.roundToInt())
}
.pointerInput(Unit) {
detectDragGesturesAfterLongPress { change, dragAmount ->
offsetX += dragAmount.x
offsetY += dragAmount.y
}
}
.size(50.dp)
.background(Color.Green)

)
Text(text = "Offset X $offsetX \nOffset Y: $offsetY")
}
}

How to detect scroll gestures?

There are few modifiers that make composable scrollable or allow us to detect the scroll. Also, some composables like LazyColumn or LazyRow comes with inbuilt scrolling, you can use the state property of the LazyListState to detect scroll events.

a. Modifier.scrollable()

Modifier.scrollable detects scroll gestures but doesn’t automatically move the content. Modifier.scrollable listens to scroll gestures and relies on the provided ScrollableState to control the scrolling behaviour of its content.

You should use Modifier.scrollable over Modifier.verticalScroll() or Modifier.horizontalScroll() when you need more fine-grained control over the scrolling behaviour and when you want to handle custom scrolling logic, such as nested scrolling or complex interactions.

@Composable
fun ScrollableDemo() {
val state = rememberScrollState()
Box(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
.scrollable(state, orientation = Orientation.Horizontal)
) {
Box(
modifier = Modifier
.padding(top = 40.dp)
.offset { IntOffset(x = state.value, y = 0) }
.size(50.dp)
.background(Color.Red)

)

Text(text = "Offset X ${state.value} ")
}
}

b. Modifier.verticalScroll() and Modifier.horizontalScroll()

In contrast to Modifier.scrollable(), Modifier.verticalScroll() and Modifier.horizontalScroll() are simpler and more straightforward options for basic scrolling needs.

They are well-suited for cases where you want to make a single Composable vertically or horizontally scrollable without the need for custom scroll handling or nested scrolling scenarios.

These modifiers provide a convenient way to enable standard scrolling behaviour with minimal configuration.

@Composable
fun HorizontalScrollDemo() {
val state = rememberScrollState()
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
Text(text = "Offset X ${state.value} ")

Row(
modifier = Modifier
.fillMaxSize()
.horizontalScroll(state) // or use .verticalScroll(...) for vertical scrolling
) {
repeat(20) {
Box(
modifier = Modifier
.padding(8.dp)
.size(50.dp)
.background(Color.Red)
)
}
}
}
}

c. Modifier.nestedScroll()

Nested scrolling enables coordinated scrolling interactions between multiple UI elements, ensuring that scroll actions propagate correctly through a hierarchy of scrollable and non-scrollable components.

The in-built scrolling component like LazyList supports nested scrolling but for non-scrollable components, we need to enable it manually with NestedScrollConnection.Let’s have a quick look at it.

There are two ways an element can participate in nested scrolling:

  • As a scrolling child, it dispatches scrolling events via a NestedScrollDispatcher to the nested scroll chain.
  • As a member of the nested scroll chain, it provides a NestedScrollConnection, which is called when another nested scrolling child below dispatches scrolling events.

You may choose to use one or both methods based on your specific use case.

Four Main Phases in Nested Scrolling:

  • Pre-scroll: Occurs when a descendant is about to perform a scroll operation. Parents can consume part of the child’s delta beforehand.
  • Post-scroll: Triggered after the descendant consumes the delta, notifying ancestors of the unconsumed delta.
  • Pre-fling: This happens when the scrolling descendant is about to fling, allowing ancestors to consume part of the velocity.
  • Post-fling: Occurs after the scrolling descendant finishes flinging, notifying ancestors about the remaining velocity to consume.

We’ll not deep dive into NestedScroll in this blog post, you can refer to the official documentation for more detail.

Let’s see a simple example, where we have nested horizontal scrolling with an anchored draggable component.

@Composable
fun NestedScrollExample() {
val density = LocalDensity.current

val state = remember {
AnchoredDraggableState(
initialValue = DragAnchors.Center,
anchors = DraggableAnchors {
DragAnchors.Start at -200f
DragAnchors.Center at 0f
DragAnchors.End at 200f
},
positionalThreshold = { distance: Float -> distance * 0.5f },
velocityThreshold = { with(density) { 100.dp.toPx() } },
animationSpec = tween(),
)
}

Box(
modifier = Modifier.fillMaxSize().padding(20.dp)
) {
Box(
modifier = Modifier
.fillMaxSize()
.anchoredDraggable(state, Orientation.Horizontal)
.offset {
IntOffset(state.requireOffset().roundToInt(), 0)
}
) {
Box(
modifier = Modifier
.padding(4.dp)
.fillMaxWidth()
.height(60.dp)
.background(Color.Black, RoundedCornerShape(10.dp))
.padding(horizontal = 10.dp)
.horizontalScroll(rememberScrollState()),
contentAlignment = Alignment.Center
) {
Text(
text = "Nested scroll modifier demo. Hello, Jetpack compose",
color = Color.White,
fontSize = 24.sp,
maxLines = 1,
textAlign = TextAlign.Center
)
}
}
}
}

You can see here, that vertical scrolling and dragging are not working together. Now let’s add nested scrolling and dispatch the offset to make composable draggable and scrollable.

val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
if ((available.x > 0f && state.offset < 0f) || (available.x < 0f && state.offset > 0f)) {
return Offset(state.dispatchRawDelta(available.x), 0f)
}
return Offset.Zero
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource,
): Offset {
return Offset(state.dispatchRawDelta(available.x), 0f)
}
}
}
 Box(
modifier = Modifier
.fillMaxSize()
.anchoredDraggable(state, Orientation.Horizontal)
.offset {
IntOffset(state.requireOffset().roundToInt(), 0)
}
.nestedScroll(nestedScrollConnection)
) {

// .... content
}

In this code:

  • We define a NestedScrollConnection to manage scroll events for a draggable component.
  • The onPreScroll function handles scroll events before they're consumed and adjusts the state of the anchored draggable component based on the scroll direction.
  • The onPostScroll function handles scroll events after they've been consumed and further adjusts the component's state.

And that’s it for the part 1.

Conclusion

In this first part of our exploration into gestures in Jetpack Compose, we’ve delved into some fundamental aspects of gesture handling that will set the stage for more advanced techniques and functionalities in Part Two.

In Part 1, we’ve covered the essentials of gestures in Jetpack Compose. We explored Gesture Modifiers, Tap Detection, Movements like Drag and Swipe, and Scroll Gestures.

In Part 2 of our series, we’ll explore advanced topics, including handling multiple gestures simultaneously, creating fling effects, manual interaction tracking, non-consuming observation, interaction disabling, and waiting for specific events.

Stay tuned for Part 2.

The post is originally published on canopas.com.

Thanks for the love you’re showing!

If you like what you read, be sure you won’t miss a chance to give 👏 👏👏 below — as a writer it means the world!

Feedback and suggestions are most welcome, add them in the comments section.

Follow Canopas to get updates on interesting articles!

--

--

Radhika S
Canopas

Android developer | Sharing knowledge of Jetpack Compose & android development