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 MotionEvent
s. 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
Modifier
s.
Some of these Modifier
s 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 Modifier
s 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 Modifier
s, 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 Modifier
s in the Compose API and how to use them.
Table of Contents
- Modifier.pointerInput
- Modifier.clickable
- Modifier.combinedClickable
- Modifier.draggable
- Modifier.scrollable
- Modifier.verticalScroll and Modifier.horizontalScroll
- Modifier.swipeable
- Modifier.transformable
- Modifier.pointerInteropFilter
Modifier.pointerInput
Modifier.pointerInput() is a flexible, low-level
Modifier
similar to OnTouchListener
. Its lambda parameter runs in PointerInputScope
, which 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
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
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.
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 Modifier
s 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
, onLongClick
, and onDoubleClick
will be ignored and clickable()
will be invoked instead.
For example, given the following code:
Example usage:
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
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
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.
Job Offers
Modifier.verticalScroll and Modifier.horizontalScroll
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.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 Modifiers
, since 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
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
Modifier.pointerInteropFilter() takes an
onTouchEvent
lambda parameter and provides access to underlying MotionEvent
s. 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.
References
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 Modifier
s 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 ❤️
Thanks to Andy Dyer
This article was originally published on proandroiddev.com on July 16, 2022