clickable does six things, and detectTapGestures does one

Modifier.clickable adds accessibility semantics, focus, keyboard activation, ripple and a minimum touch target on top of tap detection. Reaching for detectTapGestures instead silently drops all of it.

5 min read
androidcomposekotlingestures

Day 72 — clickable does six things, and detectTapGestures does one

Day 72 of 100. The most common gesture, and a choice between two APIs that look interchangeable and are not.

The symptom

A custom tappable card that feels dead:

Box(
    Modifier
        .pointerInput(item.id) {
            detectTapGestures { onOpen(item.id) }
        }
        .background(surface, shape)
) { CardContent(item) }

It responds to taps and nothing else happens. No ripple, so there's no feedback that the touch registered. TalkBack announces it as plain content with no indication it's interactive. A keyboard user can't reach it, and pressing Enter does nothing.

The tap works. Everything around the tap is missing.

Why the obvious fix fails

The obvious fix is to add the missing pieces:

Box(
    Modifier
        .pointerInput(item.id) { detectTapGestures { onOpen(item.id) } }
        .indication(interactionSource, ripple())
        .semantics { role = Role.Button; onClick { onOpen(item.id); true } }
        .focusable()
) { … }

Four modifiers reproducing what one already does — and still missing the minimum touch target, the keyboard Enter/Space handling, and the enabled state that should suppress all of it at once.

clickable isn't a thin wrapper. It's the assembly.

clickable bundles tap detection with semantics, focus, indication and a minimum target

The actual mechanism

Modifier.clickable composes six behaviours:

Modifier.clickable(
    enabled = true,
    onClickLabel = "Open message",
    role = Role.Button,
    onClick = { onOpen(item.id) },
)
  1. Tap detection, including the press-release-within-bounds rules.
  2. SemanticsRole.Button, an onClick action, the merge from Day 68.
  3. Indication — the ripple, driven by an InteractionSource.
  4. Focusability — reachable by keyboard and D-pad.
  5. Keyboard activation — Enter and Space fire onClick.
  6. Minimum touch target — 48dp via minimumInteractiveComponentSize.

detectTapGestures provides the first. That's the entire difference, and it explains why the fix list above was four modifiers long and still incomplete.

The rule: use clickable unless you need a gesture it cannot express. Double-tap, long-press-with-position, and press-and-hold-with-drag are real reasons; "I only need a tap" is not.

onClickLabel is the parameter to remember

Modifier.clickable(onClickLabel = "Open message") { … }

This changes what TalkBack announces for the action — "double tap to open message" rather than "double tap to activate". On a list of cards that all announce their content and then "activate", the label is what tells the user what activating does.

It costs one string and is the single highest-value accessibility parameter in the gestures pillar.

Long press and double tap

combinedClickable extends the same assembly:

Modifier.combinedClickable(
    onClick = { open(item) },
    onLongClick = { showContextMenu(item) },
    onLongClickLabel = "Show options",
    onDoubleClick = { toggleFavourite(item) },
)

It keeps the semantics, focus and indication, and adds long-press and double-tap semantics too — so TalkBack surfaces the long-press action in its menu rather than requiring a gesture nobody can perform.

Two design notes worth carrying. A long press should never be the only way to reach a function — Day 67's rule. And a double tap conflicts with TalkBack, where double-tap is the activation gesture, so a double-tap-only feature is unreachable for those users.

There's a latency cost too, and it's worth knowing before adding onDoubleClick everywhere. To detect a double tap, the framework must wait to see whether a second tap arrives — so a component with a double-tap handler delays its single tap response by that window. On a list where tapping opens an item, that delay is perceptible.

The trade is usually only worth it where the double tap is genuinely secondary and the single tap is not time-critical: a photo that opens on tap and zooms on double tap, for instance, where both are viewing actions.

InteractionSource: reading the press

When you want to react to press state — scale a card down while held, change elevation — the interaction source is the seam:

val interactionSource = remember { MutableInteractionSource() }
val pressed by interactionSource.collectIsPressedAsState()
val scale by animateFloatAsState(if (pressed) 0.97f else 1f, label = "scale")

Box(
    Modifier
        .graphicsLayer { scaleX = scale; scaleY = scale }
        .clickable(interactionSource = interactionSource, indication = ripple()) { open() }
)

collectIsPressedAsState, collectIsFocusedAsState, collectIsHoveredAsState and collectIsDraggedAsState cover the states you'd want. Passing the same source to clickable and to the visual response is what keeps them in sync.

Note the animation reads in the draw phase via graphicsLayer — Day 62's point, and it matters here because press feedback runs on every touch.

Removing the ripple, correctly

Sometimes the ripple is wrong — a full-bleed image, a custom press animation:

Modifier.clickable(
    interactionSource = remember { MutableInteractionSource() },
    indication = null,
) { open() }

indication = null removes the visual and keeps the other five behaviours. That's the important distinction: dropping to detectTapGestures to lose a ripple also loses accessibility and focus, and it's a trade nobody intends to make.

If you're removing the ripple, provide some feedback — the scale animation above, a colour change, a haptic. A control with no press feedback feels broken even when it works.

Haptics deserve a mention because they're one line and routinely skipped:

val haptics = LocalHapticFeedback.current
Modifier.combinedClickable(
    onClick = { open(item) },
    onLongClick = {
        haptics.performHapticFeedback(HapticFeedbackType.LongPress)
        showContextMenu(item)
    },
)

A long press with no haptic gives the user no signal that the threshold was reached, so they hold longer than needed or release too early. The platform provides the feedback type; using it keeps your app consistent with every other app on the device.

How to prove it

The gap is directly testable, and the test is short:

@Test fun cardIsAccessibleAndFocusable() = runComposeUiTest {
    setContent { ItemCard(sample, onOpen = {}) }
    onNodeWithTag("card")
        .assert(hasClickAction())
        .assert(SemanticsMatcher.keyIsDefined(SemanticsProperties.Role))
        .assertTouchHeightIsEqualTo(48.dp)
}

Against the detectTapGestures version, all three assertions fail. Against clickable, all three pass without any extra code — which is the argument in one test.

On device, three checks: tap and look for the ripple, tab to it with a keyboard, and swipe to it with TalkBack. A control that fails any of them is using the detector where the modifier belonged.

What this generalizes to

The principle is the interaction is more than the event. A tap is a pointer sequence; a button is a tap plus feedback plus a name plus a keyboard path plus a target size. Building the first and calling it the second is how interfaces end up technically functional and practically unusable.

It's the same relationship as Day 37's Switch versus a styled checkbox, and Day 43's filter chip versus an assist chip. The library's higher-level component isn't a convenience wrapper — it's the accumulated set of things the lower level doesn't know it should do.

Which suggests a useful reading habit for any component library: when a high-level API and a low-level one both seem to do the job, the interesting question is what the high-level one adds that you'd have to remember. That list is usually written down somewhere, and it is usually longer than expected.

Tomorrow, Day 73: drag, swipe and fling — gestures with velocity, and the anchored state that makes swipe-to-dismiss work.


Day 72 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Tap and press.