Tap and press

Many composables have built-in support for taps or clicks and include an onClick lambda. For example, you can create a clickable Surface that includes all Material Design behavior appropriate for interaction with surfaces:

Surface(onClick = { /* handle click */ }) {
    Text("Click me!", Modifier.padding(24.dp))
}

But clicks are not the only way a user can interact with composables. This page focuses on gestures that involve a single pointer, where the position of that pointer is not significant for the handling of that event. The following table lists these types of gestures:

Gesture

Description

Tap (or click)

Pointer goes down and then up

Double tap

Pointer goes down, up, down, up

Long-press

Pointer goes down, and is held for a longer time

Press

Pointer goes down

Respond to tap or click

clickable is a commonly used modifier that makes a composable react to taps or clicks. This modifier also adds additional features, such as support for focus, mouse and stylus hovering, and a customizable visual indication when pressed. The modifier responds to "clicks" in the widest sense of the word-- not only with mouse or finger, but also click events through keyboard input or when using accessibility services.

Imagine a grid of images, where an image shows full-screen when a user clicks on it:

You can add the clickable modifier to each item in the grid to implement this behavior:

@Composable
private fun ImageGrid(photos: List<Photo>) {
    var activePhotoId by rememberSaveable { mutableStateOf<Int?>(null) }
    LazyVerticalGrid(columns = GridCells.Adaptive(minSize = 128.dp)) {
        items(photos, { it.id }) { photo ->
            ImageItem(
                photo,
                Modifier.clickable { activePhotoId = photo.id }
            )
        }
    }
    if (activePhotoId != null) {
        FullScreenImage(
            photo = photos.first { it.id == activePhotoId },
            onDismiss = { activePhotoId = null }
        )
    }
}

The clickable modifier also adds additional behavior:

  • interactionSource and indication, which draw a ripple by default when a user taps the composable. Learn how to customize these on the Handling user interactions page.
  • Allows accessibility services to interact with the element by setting the semantics information.
  • Supports keyboard or joystick interaction by allowing focus and pressing Enter or the center of the d-pad to interact.
  • Make the element hoverable, so it responds to the mouse or stylus hovering over it.

Long-press to show a contextual context menu

combinedClickable lets you add double tap or long-press behavior in addition to normal click behavior. You can use combinedClickable to show a context menu when a user touches and holds a grid image:

var contextMenuPhotoId by rememberSaveable { mutableStateOf<Int?>(null) }
val haptics = LocalHapticFeedback.current
LazyVerticalGrid(columns = GridCells.Adaptive(minSize = 128.dp)) {
    items(photos, { it.id }) { photo ->
        ImageItem(
            photo,
            Modifier
                .combinedClickable(
                    onClick = { activePhotoId = photo.id },
                    onLongClick = {
                        haptics.performHapticFeedback(HapticFeedbackType.LongPress)
                        contextMenuPhotoId = photo.id
                    },
                    onLongClickLabel = stringResource(R.string.open_context_menu)
                )
        )
    }
}
if (contextMenuPhotoId != null) {
    PhotoActionsSheet(
        photo = photos.first { it.id == contextMenuPhotoId },
        onDismissSheet = { contextMenuPhotoId = null }
    )
}

As a best practice, you should include haptic feedback when the user long-presses elements, which is why the snippet includes the performHapticFeedback invocation.

Dismiss a composable by tapping a scrim

In the examples above, clickable and combinedClickable add useful functionality to your composables. They show a visual indication on interaction, respond to hovering, and include focus, keyboard, and accessibility support. But this extra behavior is not always desirable.

Let's look at the image detail screen. The background should be semi-transparent and the user should be able to tap that background to dismiss the detail screen:

In this case, that background should not have any visual indication on interaction, should not respond to hovering, should not be focusable, and its response to keyboard and accessibility events differ from that of a typical composable. Instead of trying to adapt the clickable behavior, you can drop down to a lower abstraction level and directly use the pointerInput modifier in combination with the detectTapGestures method:

@Composable
private fun Scrim(onClose: () -> Unit, modifier: Modifier = Modifier) {
    val strClose = stringResource(R.string.close)
    Box(
        modifier
            // handle pointer input
            .pointerInput(onClose) { detectTapGestures { onClose() } }
            // handle accessibility services
            .semantics(mergeDescendants = true) {
                contentDescription = strClose
                onClick {
                    onClose()
                    true
                }
            }
            // handle physical keyboard input
            .onKeyEvent {
                if (it.key == Key.Escape) {
                    onClose()
                    true
                } else {
                    false
                }
            }
            // draw scrim
            .background(Color.DarkGray.copy(alpha = 0.75f))
    )
}

As the key of the pointerInput modifier you pass the onClose lambda. This automatically re-executes the lambda, making sure the right callback is called when the user taps the scrim.

Double tap to zoom

Sometimes clickable and combinedClickable do not include enough information to respond to the interaction in the correct way. For example, composables might need access to the position within the composable's bounds where the interaction took place.

Let's look at the image detail screen again. A best practice is to make it possible to zoom in on the image by double tapping:

As you can see in the video, zooming in occurs around the position of the tap event. The result is different when we zoom in on the left part of the image versus the right part. We can use the pointerInput modifier in combination with the detectTapGestures to incorporate the tap position into our calculation:

var zoomed by remember { mutableStateOf(false) }
var zoomOffset by remember { mutableStateOf(Offset.Zero) }
Image(
    painter = rememberAsyncImagePainter(model = photo.highResUrl),
    contentDescription = null,
    modifier = modifier
        .pointerInput(Unit) {
            detectTapGestures(
                onDoubleTap = { tapOffset ->
                    zoomOffset = if (zoomed) Offset.Zero else
                        calculateOffset(tapOffset, size)
                    zoomed = !zoomed
                }
            )
        }
        .graphicsLayer {
            scaleX = if (zoomed) 2f else 1f
            scaleY = if (zoomed) 2f else 1f
            translationX = zoomOffset.x
            translationY = zoomOffset.y
        }
)