Gesture-Handling Modifiers in Jetpack Compose | by Sherry Yuan

Photo by Ebuen Clemente Jr on Unsplash

Jetpack Compose is Android’s modern UI toolkit, where UI elements are built with declarative Composable functions. It offers a new set of APIs to help detect user gestures. If you haven’t worked with Compose yet, I suggest learning its basics before reading this article.

This article is part of my Android Touch System series and assumes readers have some understanding of MotionEvents. If you’re unfamiliar with them, please check out Part 1: Touch Functions and the View Hierarchy.

Compose uses Modifiers to configure various attributes for Composables, including padding and accessibility labels. Gesture detection is also added through Modifiers.

Some of these Modifiers are high-level and cover commonly used gestures. For example, Modifier.clickable() allows simple click detection, and also displays visual indicators such as ripples when the Composable is clicked. Other Modifiers offer more flexibility on a lower level, and can be used to detect less common gestures.

There are also a few Composables that use lambda parameters for handling gestures instead of Modifiers, such asButton() with its onClick: () -> Unit parameter, but they’re the exception rather than the rule.

The rest of this article will go over the gesture-related Modifiers in the Compose API and how to use them.

Modifier.pointerInput() is a flexible, low-level Modifier similar to OnTouchListener. Its lambda parameter runs in PointerInputScopewhich gives us access to the pointer size, event, and other fields useful for handling pointer input.

PointerInputScope also provides functions like detectTapGestures() and detectDragGestures() for detecting various gestures. detectTapGestures() is a useful alternative to Modifier.clickable() if we need the exact position of the tap, or want to add custom visual changes or accessibility indicators.

Example usage:

Box(modifier = Modifier.pointerInput(Unit) {
detectTapGestures(
onTap = { Log.d(TAG, “Box tapped at ${it.x}, ${it.y}”) },
onDoubleTap = {
Log.d(TAG, “Box double tapped at ${it.x}, ${it.y}”)
}
)
})

Modifier.clickable() listens for single clicks, and is equivalent to OnClickListener in traditional views. It calls Modifier.pointerInput { detectTapAndPress() } under the hood, but includes visual and accessibility indicators in addition to invoking the click callback. It’s more succinct and easier to use than pointerInput()and is enough for most click handling.

Example usage:

Box(modifier = Modifier.clickable {
Log.d(TAG, “Box clicked”)
})

Modifier.combinedClickable() listens for single, double, and long clicks. Its nearest equivalent in the view world is GestureDetector; it only handles a subset of gestures supported by GestureDetector, but is much simpler to use. Like Modifier.clickable()it calls Modifier.pointerInput { detectTapGestures() } under the hood.

Example usage:

Box(modifier = Modifier.combinedClickable(
onClick = { Log.d(TAG, “Box clicked”) },
onDoubleClick = { Log.d(TAG, “Box double clicked”)},
onLongClick = { Log.d(TAG, “Box long clicked”)}
)

What if we use clickable() and combinedClickable() together?

If both Modifiers are set on the same Composable, the later one in the Modifier chain will be used. If clickable() comes after combinedClickable()all of combinedClickable()‘s onClick, onLongClickand onDoubleClick will be ignored and clickable() will be invoked instead.

For example, given the following code:

Box(modifier = Modifier
.combinedClickable(
onClick = { Log.d(TAG, “Click in combinedClickable”) },
onDoubleClick = {
Log.d(TAG, “Double click in combinedClickable”)
},
onLongClick = {
Log.d(TAG, “Long click in combinedClickable”)
}
)
.clickable { Log.d(TAG, “Click in clickable”) }
)

Only “Click in clickable” will be logged when the box is clicked, whether it’s a normal, double, or long click.

For any Composables with an explicit onClick parameter, the onClick lambda will override any click callbacks in the modifier chain.

This is because eventually, the onClick parameter gets added to the end of the modifier chain. We can see this in the Button implementation:

Button(
onClick: () -> Unit,
modifier: Modifier = Modifier,

) {

}

Following its function calls, we see a call to Surface:

Surface(
modifier = modifier.minimumTouchTargetSize(),

clickAndSemanticsModifier = Modifier
.clickable(onClick = onClick)
)

Which in turn calls Box and adds clickAndSemanticsModifier to the end of the Modifier chain:

Box(
modifier
.…
.then(clickAndSemanticsModifier)
) {

}

As a result, given this:

Button(
onClick = { Log.d(TAG, “Click in onClick”) },
modifier = Modifier.clickable {
Log.d(TAG, “Click in clickable”)
}
)

Only “click in onClick” will be logged when the button is clicked.

Modifier.draggable() detects the motion where a user puts a finger down, drags it across the screen, then lifts it. It’s a helpful Modifier that doesn’t have an equivalent in the View world; drag gestures traditionally require doing complicated state management and calculations inside onTouchListener.

Example usage:

val state = rememberDraggableState(
onDelta = { delta -> Log.d(TAG, “Dragged $delta”) }
)
Box(modifier = Modifier.draggable(
state = state,
orientation = Orientation.Vertical,
onDragStarted = { Log.d(TAG, “Drag started”) },
onDragStopped = { Log.d(TAG, “Drag ended”) }
))

To detect more nuanced drag detection that includes movement in both x and y directions, Compose also provides detectDragGestures in pointerInput:

Modifier.pointerInput(Unit) {
detectDragGestures { change, dragAmount ->
Log.d(TAG, “dragged x: ${dragAmount.x}”)
Log.d(TAG, “dragged y: ${dragAmount.y}”)
}
}

Modifier.scrollable() is similar to GestureDetector.SimpleOnGestureListener‘s onScroll(). Its implementation actually calls draggable(). The main difference between the two is that draggable() only detects the gesture, whereas scrollable() Both detects and moves the composable on the screen based on the result of consumeScrollDelta.

Example usage:

val scrollableState = rememberScrollableState(
consumeScrollDelta = {
delta -> Log.d(TAG, “scrolled $delta”)
0f
}
)
Box(modifier = Modifier.scrollable(
state = scrollableState,
orientation = Orientation.Vertical
))

The composable will move by the difference between delta and the return value of consumeScrollDelta. Returning 0f from the lambda means none of the scroll was consumed, and the composable will move by delta pixels.

They both call scrollable() under the hood, and are easier to use as long as we don’t need to access delta. They can also be used together to detect scrolls in both directions, and won’t override each other.

Example usage:

Box(modifier = Modifier
.verticalScroll(rememberScrollState())
.horizontalScroll(rememberScrollState())
)

Modifier.nestedScroll() also exists, but it’s a bit more complicated and I won’t explore it in this article.

Modifier.swipeable() modifier also listens for drag gestures. When the drag is released, the Composable will animate to an anchor state, which we can set using the anchors parameter. Two common use cases are creating a composable with expanded and collapsed anchor states, or implementing ‘swipe-to-dismiss’. This is slightly different from the concept of swipe gestures in traditional Android views, where “swipe” is often used interchangeably with “fling”.

Here’s an example similar to the Android documentation, with more obvious states:

@Composable
fun SwipeableDemo() {
val width = 300.dp
val squareSize = 100.dp
val swipeableState = rememberSwipeableState(States.LEFT)
val squareSizePx = with(LocalDensity.current) {
(width — squareSize).toPx()
}
Box(
modifier = Modifier
.width(width)
.swipeable(
state = swipeableState,
anchors = mapOf(
0f to States.LEFT,
squareSizePx to States.RIGHT
),
thresholds = { _, _ -> FractionalThreshold(0.5f) },
orientation = Orientation.Horizontal
)
.background(Color.LightGray)
) {
Box(
modifier = Modifier
.offset {
IntOffset(swipeableState.offset.value.toInt(), 0)
}
.size(squareSize)
.background(Color.DarkGray)
)
}
}
enum class States { LEFT, RIGHT }

Like the two clickable Modifierssince draggable(), scrollable()and swipeable() use the same drag gesture detection under the hood, whichever one comes later in the Modifier chain will be triggered.

Modifier.transformable() Detects multi-touch gestures used for panning, zooming and rotating. It’s similar to ScaleGestureDetector in the traditional view world. It provides the transformation’s scale, rotation and offset, but doesn’t handle the graphics transformations directly. Developers have to implement the transformations in rememberTransformableState {}.

@Composable
fun TransformableDemo() {
var scale by remember { mutableStateOf(1f) }
var rotation by remember { mutableStateOf(0f) }
var offset by remember { mutableStateOf(Offset.Zero) }
val state = rememberTransformableState {
zoomChange, offsetChange, rotationChange ->
scale *= zoomChange
rotation += rotationChange
offset += offsetChange
}

Box(
modifier = Modifier
.graphicsLayer(
scaleX = scale,
scaleY = scale,
rotationZ = rotation,
translationX = offset.x,
translationY = offset.y
)
.transformable(state = state)
.background(Color.Blue)
.fillMaxSize()
)
}

Modifier.pointerInteropFilter() takes an onTouchEvent lambda parameter and provides access to underlying MotionEvents. It’s included in the Compose API for interop support, so that developers can continue using any custom touch handling they’ve already implemented.

Example usage:

class DemoOnTouchListener : View.OnTouchListener {
override fun onTouch(v: View, event: MotionEvent): Boolean {
Log.d(“TAG, “Detecting motion event ${event.action}”)
return false
}
}
// In a Composable
val listener = DemoOnTouchListener()
val view = LocalView.current
Box(
modifier = Modifier
.pointerInteropFilter { motionEvent ->
listener.onTouch(view, motionEvent)
}
)

The onTouchEvent lambda has a Boolean return type, which is the same return type as View.onTouchEvent. Just like the View world, if the provided onTouchEvent returns true, the lambda will continue to receive any future events as long as they’re not intercepted.

Here are the links to Part 1: Touch Functions and the View Hierarchy, Part 2: Common Touch Event Scenarios, and Part 3: MotionEvent Listeners of my Android Touch System series.

Part 5: How Gestures Work in Jetpack Compose covers how pointer events work in the Compose hierarchy, some limitations of gesture detection in Compose, and custom Modifiers for overcoming the limitations.

I originally planned to do a single article for gestures in Compose but it was getting too long ¯_(ツ)_/¯

Thanks to Russell and Kelvin for their valuable editing and feedback ❤️

Leave a Comment