Blog Infos
Author
Published
Topics
Published

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.

Table of Contents
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 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 onClickonLongClick, 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

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

, ,

Cutting-Edge-to-Edge in Android 15: Using Previews and Testing in Jetpack Compose to Manage Insets.

With the advent of Android 15, edge-to-edge design has become the default configuration. Consequently, applications must be capable of accommodating window insets, including the system status bar and navigation bar, as well as supporting drawing…
Watch Video

Cutting-Edge-to-Edge in Android 15: Using Previews and Testing in Jetpack Compose to Manage Insets.

Timo Drick
Lead Android developer
Seven Principles Mobility GmbH

Cutting-Edge-to-Edge in Android 15: Using Previews and Testing in Jetpack Compose to Manage Insets.

Timo Drick
Lead Android develop ...
Seven Principles Mob ...

Cutting-Edge-to-Edge in Android 15: Using Previews and Testing in Jetpack Compose to Manage Insets.

Timo Drick
Lead Android developer
Seven Principles Mobility ...

Jobs

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 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.

References

Here are the links to Part 1: Touch Functions and the View HierarchyPart 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 ❤️

Thanks to Andy Dyer

This article was originally published on proandroiddev.com on July 16, 2022

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
It’s one of the common UX across apps to provide swipe to dismiss so…
READ MORE
blog
In this part of our series on introducing Jetpack Compose into an existing project,…
READ MORE
blog
In the world of Jetpack Compose, where designing reusable and customizable UI components is…
READ MORE
blog

How to animate BottomSheet content using Jetpack Compose

Early this year I started a new pet project for listening to random radio…
READ MORE
Menu